From e42dd11ec61378a8cf718ac266bef67746e8aa40 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 10 Nov 2025 13:21:48 +0100 Subject: [PATCH 01/71] chore: tasks migration plan --- packages/tasks/MIGRATION.md | 235 ++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 packages/tasks/MIGRATION.md diff --git a/packages/tasks/MIGRATION.md b/packages/tasks/MIGRATION.md new file mode 100644 index 00000000000..c7d65c464d1 --- /dev/null +++ b/packages/tasks/MIGRATION.md @@ -0,0 +1,235 @@ +Refactoring Plan for packages/tasks + +Based on my analysis of the current packages/tasks implementation and comparison with the +packages/api-core architecture, here's the refactoring plan: + +Current State Analysis + +The packages/tasks package currently uses: +- Plugin-based task definitions via TaskDefinitionPlugin class +- Context-based CRUD operations injected via ContextPlugin +- Old plugin architecture to register task definitions and services +- Mixed concerns across multiple files without clear abstraction boundaries + +Target Architecture (like api-core) + +The refactored package should use: +- Abstractions defined using createAbstraction from @webiny/feature/api +- Feature-based registration using createFeature from @webiny/feature/api +- DI container for dependency injection instead of plugins +- Clean separation of concerns with proper use cases, repositories, and gateways + + --- +Detailed Refactoring Plan + +1. Create Abstractions for Task Definition Registry + +Current: Task definitions are registered via TaskDefinitionPlugin plugins +Target: Create abstraction for task definition registry + +packages/tasks/src/features/shared/abstractions.ts + +Create abstractions for: +- TaskDefinitionRegistry - Interface to register and retrieve task definitions +- TaskDefinition - Interface representing a task definition (not a plugin) +- TaskExecutor - Interface for executing tasks +- TaskRepository - Interface for CRUD operations on tasks +- TaskLogRepository - Interface for CRUD operations on task logs + +2. Convert Task Definition from Plugin to Abstraction Implementation + +Current: TaskDefinitionPlugin extends Plugin class +Target: Create abstraction and implementation pattern + +Files to create: +packages/tasks/src/features/shared/abstractions/TaskDefinition.ts +packages/tasks/src/features/shared/TaskDefinitionRegistryImpl.ts + +The task definition should become: +- An abstraction interface that defines the contract +- Implementations registered via createImplementation +- No longer a plugin class + +3. Refactor CRUD Operations to Use Cases + +Current: CRUD operations in crud/ directory mixed with context +Target: Separate use cases for each operation + +Create features: +packages/tasks/src/features/CreateTask/ +- abstractions.ts (CreateTask abstraction, errors, types) +- CreateTaskUseCase.ts (implementation) +- feature.ts (DI registration) +- events.ts (TaskBeforeCreateEvent, TaskAfterCreateEvent) + +packages/tasks/src/features/GetTask/ +- abstractions.ts +- GetTaskUseCase.ts +- feature.ts + +packages/tasks/src/features/ListTasks/ +- abstractions.ts +- ListTasksUseCase.ts +- feature.ts + +packages/tasks/src/features/UpdateTask/ +- abstractions.ts +- UpdateTaskUseCase.ts +- feature.ts + +packages/tasks/src/features/DeleteTask/ +- abstractions.ts +- DeleteTaskUseCase.ts +- feature.ts + +packages/tasks/src/features/TriggerTask/ +- abstractions.ts +- TriggerTaskUseCase.ts +- feature.ts + +packages/tasks/src/features/AbortTask/ +- abstractions.ts +- AbortTaskUseCase.ts +- feature.ts + +4. Refactor Task Execution to Use Case Pattern + +Current: TaskRunner and TaskManager classes handle execution +Target: Convert to use case with proper abstractions + +packages/tasks/src/features/ExecuteTask/ +- abstractions.ts (ExecuteTask abstraction) +- ExecuteTaskUseCase.ts +- feature.ts + +5. Create Repository Implementations + +Current: Direct database access via CMS models +Target: Repository pattern with gateway for database access + +packages/tasks/src/features/shared/TaskRepository.ts +packages/tasks/src/features/shared/TaskRepositoryGateway.ts +packages/tasks/src/features/shared/TaskLogRepository.ts +packages/tasks/src/features/shared/TaskLogRepositoryGateway.ts + +Register repositories in singleton scope: +container.register(TaskRepositoryImpl).inSingletonScope(); +container.register(TaskLogRepositoryImpl).inSingletonScope(); + +6. Convert Task Service Plugins to Abstractions + +Current: TaskServicePlugin abstract class with implementations +Target: Create abstraction for task service + +packages/tasks/src/features/shared/abstractions/TaskService.ts + +Create implementations: +packages/tasks/src/service/StepFunctionTaskService.ts (implementation) +packages/tasks/src/service/EventBridgeTaskService.ts (implementation) + +Register via: +container.registerInstance(TaskService, new StepFunctionTaskService(...)); + +7. Create Main Tasks Feature + +Create a top-level feature that composes all sub-features: + +```ts +// packages/tasks/src/features/TasksFeature.ts + +export const TasksFeature = createFeature({ + name: "Tasks", + register(container) { + // Register shared components + container.register(TaskRepositoryImpl).inSingletonScope(); + container.register(TaskLogRepositoryImpl).inSingletonScope(); + container.register(TaskDefinitionRegistryImpl).inSingletonScope(); + + // Register use case features + CreateTaskFeature.register(container); + GetTaskFeature.register(container); + ListTasksFeature.register(container); + UpdateTaskFeature.register(container); + DeleteTaskFeature.register(container); + TriggerTaskFeature.register(container); + AbortTaskFeature.register(container); + ExecuteTaskFeature.register(container); + } +}); +``` + +8. Replace Context Plugin with DI Container Resolution + +Current: Context injected via ContextPlugin +Target: Resolve from DI container when needed + +Instead of context.tasks.*, consumers will: +```ts +const createTask = container.resolve(CreateTask); +await createTask.execute(input); +``` + +9. Update Public API (index.ts) +```ts +// Export main feature +export { TasksFeature } from "./features/TasksFeature.js"; + +// Export abstractions +export { CreateTask } from "./features/CreateTask/abstractions.js"; +export { GetTask } from "./features/GetTask/abstractions.js"; +export { TriggerTask } from "./features/TriggerTask/abstractions.js"; +export { TaskDefinition } from "./features/shared/abstractions.js"; + +// Export types +export type { Task, TaskInput, TaskLog } from "./features/shared/types.js"; + +// Export domain events +export { TaskBeforeCreateEvent, TaskAfterCreateEvent } from "./features/CreateTask/events.js"; + +// DO NOT export implementations +``` + +10. Migration Path + +To maintain backward compatibility during migration: + +1. Phase 1: Create new abstractions and implementations alongside existing plugin system +2. Phase 2: Add adapter layer that wraps old plugins as new abstractions +3. Phase 3: Migrate consumers to use new abstractions +4. Phase 4: Remove old plugin-based code + +11. Error Handling + +Create domain-specific errors: + +packages/tasks/src/features/shared/errors.ts + +export class TaskNotFoundError extends BaseError { ... } +export class TaskValidationError extends BaseError { ... } +export class TaskExecutionError extends BaseError { ... } +export class TaskDefinitionNotFoundError extends BaseError { ... } + + --- +Key Benefits + +1. Clear separation of concerns - Each use case is independent +2. Type-safe dependency injection - No more context pollution +3. Testability - Easy to mock dependencies +4. Composability - Features can be composed and reused +5. No plugin system overhead - Direct DI container usage +6. Event-driven architecture - Domain events for cross-cutting concerns +7. Consistent with api-core - Same patterns across codebase + + --- +Summary + +This refactoring converts the tasks package from a plugin-based architecture to a feature-based +architecture using: +- Abstractions instead of plugin classes +- Use cases for business logic +- Repositories for data access +- Features for DI registration +- Domain events for lifecycle hooks +- Result types for error handling + +The new architecture aligns with packages/api-core and follows clean architecture principles. From e37a141205b71ef021a1782493dc99f229a8a31f Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 10 Nov 2025 17:15:25 +0100 Subject: [PATCH 02/71] chore: hcms migration plan --- packages/api-headless-cms/MIGRATION_PLAN.md | 1295 +++++++++++++++++++ 1 file changed, 1295 insertions(+) create mode 100644 packages/api-headless-cms/MIGRATION_PLAN.md diff --git a/packages/api-headless-cms/MIGRATION_PLAN.md b/packages/api-headless-cms/MIGRATION_PLAN.md new file mode 100644 index 00000000000..e31d2b408e2 --- /dev/null +++ b/packages/api-headless-cms/MIGRATION_PLAN.md @@ -0,0 +1,1295 @@ +# API Headless CMS - Clean Architecture Migration Plan + +## Executive Summary + +This document outlines the migration plan for `@webiny/api-headless-cms` from its current CRUD-based architecture to a Clean Architecture with domain-driven design (DDD), following the patterns established in `@webiny/api-core`. + +**Migration Goals:** +1. ✅ Break down monolithic CRUD files into domain-specific features +2. ✅ Implement proper use cases with dependency injection +3. ✅ Create unified repository pattern for DB + code-defined models +4. ✅ Establish domain events with proper event handlers +5. ✅ Maintain backward compatibility during migration +6. ⚠️ Keep model plugins in legacy format (out of scope for Phase 1) + +--- + +## Current Architecture Analysis + +### Package Structure (Before Migration) + +``` +packages/api-headless-cms/src/ +├── crud/ +│ ├── contentModel.crud.ts # 800+ lines - Model CRUD +│ ├── contentModelGroup.crud.ts # 400+ lines - Group CRUD +│ ├── contentEntry.crud.ts # 1800+ lines - Entry CRUD orchestrator +│ └── contentEntry/ +│ ├── useCases/ # 47 use case files (already structured!) +│ ├── beforeCreate.ts # Lifecycle hooks +│ ├── beforeUpdate.ts +│ └── entryDataFactories/ # Data transformation +├── types/ +│ ├── types.ts # 2400+ lines of type definitions +│ ├── context.ts # Context interfaces +│ └── plugins.ts # Plugin types +├── plugins/ +│ ├── CmsModelPlugin.ts # Code-defined models +│ ├── CmsGroupPlugin.ts # Code-defined groups +│ └── ... # Field type plugins +├── storage/ # Storage transform plugins +├── utils/ # Utilities and helpers +└── graphql/ # GraphQL schema generation +``` + +### Identified Problems + +1. **Monolithic CRUD files** - contentEntry.crud.ts is 1800+ lines +2. **Mixed responsibilities** - CRUD files handle orchestration, validation, events, transforms +3. **No domain boundaries** - Publishing, deletion, revisions all mixed together +4. **Dual model sources** - DB models and plugin models handled inconsistently +5. **Event system fragmentation** - Both pub/sub topics AND DI-based hooks exist +6. **Storage operations exposed** - Direct storage calls throughout codebase + +--- + +## Target Architecture (After Migration) + +### Domain Identification + +Based on analysis, we've identified **3 primary domains** with clear boundaries: + +#### 1. **Content Models Domain** +Management of content model definitions (schemas). + +**Responsibilities:** +- Define and validate model schemas +- Manage model lifecycle (create, update, delete) +- Combine DB-stored and code-defined models +- Handle model versioning +- Model field validation + +**Key Entities:** `CmsModel`, `CmsModelField` + +#### 2. **Content Model Groups Domain** +Organization and categorization of content models. + +**Responsibilities:** +- Manage model groups +- Group membership +- Group access control +- Combine DB-stored and code-defined groups + +**Key Entities:** `CmsGroup` + +#### 3. **Content Entries Domain** +The largest domain - managing actual content data. + +**Sub-domains:** +- **Entry Lifecycle** - Create, update, validate entries +- **Entry Publishing** - Publish, unpublish, republish workflows +- **Entry Deletion** - Soft delete (bin), hard delete, restore +- **Entry Revisions** - Revision management and history +- **Entry Retrieval** - List, get, search, filter entries +- **Entry Location** - Move entries between folders/locations + +**Key Entities:** `CmsEntry`, `CmsEntryMeta`, `CmsStorageEntry` + +--- + +## Migration Strategy + +### Phase 1: Foundation (Week 1-2) + +#### 1.1 Create Domain and Feature Structure + +``` +packages/api-headless-cms/src/ +├── domains/ +│ ├── contentModels/ +│ │ ├── CmsModel.ts # Domain entity/model +│ │ ├── CmsModelField.ts # Domain entity +│ │ ├── ModelValidator.ts # Domain service +│ │ ├── abstractions.ts # Domain abstractions +│ │ ├── errors.ts # Domain errors +│ │ └── types.ts # Domain types +│ │ +│ ├── contentModelGroups/ +│ │ ├── CmsGroup.ts # Domain entity +│ │ ├── abstractions.ts # Domain abstractions +│ │ ├── errors.ts # Domain errors +│ │ └── types.ts # Domain types +│ │ +│ └── contentEntries/ +│ ├── CmsEntry.ts # Domain entity +│ ├── CmsEntryMeta.ts # Domain value object +│ ├── EntryValidator.ts # Domain service +│ ├── EntryTransformer.ts # Domain service +│ ├── abstractions.ts # Domain abstractions +│ ├── errors.ts # Domain errors +│ └── types.ts # Domain types +│ +├── features/ # Application layer (use cases) +│ ├── contentModels/ +│ │ ├── CreateModel/ +│ │ │ ├── abstractions.ts +│ │ │ ├── CreateModelUseCase.ts +│ │ │ ├── events.ts +│ │ │ └── feature.ts +│ │ ├── UpdateModel/ +│ │ ├── DeleteModel/ +│ │ ├── GetModel/ +│ │ ├── ListModels/ +│ │ └── shared/ +│ │ ├── abstractions.ts # ModelsRepository +│ │ ├── ModelsRepository.ts # Infrastructure (DB + plugins) +│ │ └── PluginModelsProvider.ts +│ │ +│ ├── contentModelGroups/ +│ │ ├── CreateGroup/ +│ │ ├── UpdateGroup/ +│ │ ├── DeleteGroup/ +│ │ ├── GetGroup/ +│ │ ├── ListGroups/ +│ │ └── shared/ +│ │ ├── abstractions.ts # GroupsRepository +│ │ ├── GroupsRepository.ts # Infrastructure (DB + plugins) +│ │ └── PluginGroupsProvider.ts +│ │ +│ └── contentEntries/ +│ ├── CreateEntry/ +│ │ ├── abstractions.ts +│ │ ├── CreateEntryUseCase.ts +│ │ ├── decorators/ +│ │ │ ├── CreateEntrySecureDecorator.ts +│ │ │ └── CreateEntryValidationDecorator.ts +│ │ ├── events.ts +│ │ └── feature.ts +│ ├── UpdateEntry/ +│ ├── DeleteEntry/ +│ ├── PublishEntry/ +│ ├── UnpublishEntry/ +│ ├── RepublishEntry/ +│ ├── GetEntry/ +│ ├── ListEntries/ +│ ├── MoveEntry/ +│ ├── RestoreEntry/ +│ ├── CreateRevision/ +│ ├── GetRevisions/ +│ ├── DeleteRevision/ +│ └── shared/ +│ ├── abstractions.ts # EntriesRepository +│ └── EntriesRepository.ts # Infrastructure +│ +├── legacy/ # Backward compatibility layer +│ ├── crud/ # Keep original files temporarily +│ │ ├── contentModel.crud.ts +│ │ ├── contentModelGroup.crud.ts +│ │ └── contentEntry.crud.ts +│ └── adapters/ # Adapters from legacy to new +│ └── LegacyContextAdapter.ts +│ +└── types/ # Keep for now, gradually migrate + ├── types.ts + ├── context.ts + └── plugins.ts +``` + +**Key Architecture Layers:** + +| Layer | Location | Responsibility | Examples | +|-------|----------|---------------|----------| +| **Domain** | `src/domains/` | Business entities, value objects, domain services, domain logic | `CmsModel`, `CmsEntry`, `ModelValidator` | +| **Application** | `src/features/` | Use cases, repositories, application services, orchestration | `CreateModelUseCase`, `ModelsRepository` | +| **Infrastructure** | `src/features/*/shared/` | External concerns, storage, plugins | Repository implementations | +| **Legacy** | `src/legacy/` | Backward compatibility, adapters | `LegacyContextAdapter` | + +#### 1.2 Create Domain Layer + +**Example: Domain Entity** + +```typescript +// domains/contentModels/CmsModel.ts +import type { CmsModelField } from "./CmsModelField.js"; + +export interface CmsModel { + modelId: string; + name: string; + singularApiName: string; + pluralApiName: string; + fields: CmsModelField[]; + layout?: string[][]; + group: string; + description?: string; + tenant: string; + locale: string; + createdOn: string; + savedOn: string; + createdBy: Record; +} +``` + +**Example: Domain Service** + +```typescript +// domains/contentModels/ModelValidator.ts +import type { CmsModel } from "./CmsModel.js"; + +export class ModelValidator { + validate(model: CmsModel): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!model.modelId) { + errors.push("modelId is required"); + } + + if (!model.name) { + errors.push("name is required"); + } + + // Domain validation logic... + + return { valid: errors.length === 0, errors }; + } +} +``` + +#### 1.3 Create Feature Abstractions + +**Example: Repository Abstraction (Application Layer)** + +```typescript +// features/contentModels/shared/abstractions.ts +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; +import type { ModelNotFoundError, ModelStorageError } from "~/domains/contentModels/errors.js"; + +export interface IModelsRepositoryErrors { + base: ModelNotFoundError | ModelStorageError; +} + +type RepositoryError = IModelsRepositoryErrors[keyof IModelsRepositoryErrors]; + +/** + * ModelsRepository follows CQS (Command-Query Separation): + * - Queries (get, list): Return data wrapped in Result + * - Commands (create, update, delete): Return Result + */ +export interface IModelsRepository { + // Queries - return data + get(modelId: string): Promise>; + list(): Promise>; + + // Commands - return void (side effects only) + create(model: CmsModel): Promise>; + update(modelId: string, data: Partial): Promise>; + delete(modelId: string): Promise>; +} + +export const ModelsRepository = createAbstraction("ModelsRepository"); + +export namespace ModelsRepository { + export type Interface = IModelsRepository; + export type Error = RepositoryError; +} +``` + +#### 1.4 Implement Repository Pattern for Dual Sources + +**Key Innovation: Unified Repository for DB + Code Models** + +Following the pattern from `api-core` (GroupProvider/TeamProvider), create repositories that transparently handle both database-stored and plugin-defined models. + +```typescript +// features/contentModels/shared/ModelsRepository.ts +import { createImplementation } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ModelsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelNotFoundError, ModelStorageError } from "~/domains/contentModels/errors.js"; +import type { HeadlessCmsStorageOperations } from "~/types/index.js"; +import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; + +class ModelsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private storageOperations: HeadlessCmsStorageOperations, + private pluginModels: CmsModel[], // Injected from plugin registry + private accessControl: AccessControl.Interface + ) {} + + async get(modelId: string): Promise> { + // 1. Check plugin models first (code-defined, cached) + const pluginModel = this.pluginModels.find(m => m.modelId === modelId); + if (pluginModel) { + const canAccess = await this.accessControl.canAccessModel({ model: pluginModel }); + if (!canAccess) { + return Result.fail(new ModelNotFoundError(modelId)); + } + return Result.ok(pluginModel); + } + + // 2. Query database models + try { + const dbModel = await this.storageOperations.models.get({ modelId }); + if (!dbModel) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + const canAccess = await this.accessControl.canAccessModel({ model: dbModel }); + if (!canAccess) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + return Result.ok(dbModel); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async list(): Promise> { + try { + // 1. Get DB models + const dbModels = await this.storageOperations.models.list(); + + // 2. Combine with plugin models + const allModels = [...this.pluginModels, ...dbModels]; + + // 3. Apply access control + const accessibleModels = await Promise.all( + allModels.map(async model => { + const canAccess = await this.accessControl.canAccessModel({ model }); + return canAccess ? model : null; + }) + ); + + return Result.ok(accessibleModels.filter(Boolean) as CmsModel[]); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async create(model: CmsModel): Promise> { + // Only DB models can be created (plugin models are code-defined) + try { + await this.storageOperations.models.create(model); + return Result.ok(); // ✅ CQS: Commands return void + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async update( + modelId: string, + data: Partial + ): Promise> { + // Cannot update plugin models + const pluginModel = this.pluginModels.find(m => m.modelId === modelId); + if (pluginModel) { + return Result.fail( + new ModelStorageError( + new Error("Cannot update code-defined models") + ) + ); + } + + try { + await this.storageOperations.models.update(modelId, data); + return Result.ok(); // ✅ CQS: Commands return void + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async delete(modelId: string): Promise> { + // Cannot delete plugin models + const pluginModel = this.pluginModels.find(m => m.modelId === modelId); + if (pluginModel) { + return Result.fail( + new ModelStorageError( + new Error("Cannot delete code-defined models") + ) + ); + } + + try { + await this.storageOperations.models.delete(modelId); + return Result.ok(); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } +} + +export const ModelsRepositoryImpl = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: ModelsRepositoryImpl, + dependencies: [ + HeadlessCmsStorageOperations, + PluginModelsProvider, // New: provides plugin models + AccessControl + ] +}); +``` + +--- + +### Phase 2: Use Case Implementation (Week 3-4) + +#### 2.0 CQS (Command-Query Separation) Principle + +**All repositories and use cases MUST follow CQS principle:** + +| Type | Returns | Side Effects | Examples | +|------|---------|--------------|----------| +| **Query** | `Result` | No side effects (read-only) | `get()`, `list()`, `find()` | +| **Command** | `Result` | Has side effects (write) | `create()`, `update()`, `delete()` | + +**Repository Pattern with CQS:** + +```typescript +interface IModelsRepository { + // ✅ Queries - return data + get(id: string): Promise>; + list(): Promise>; + + // ✅ Commands - return void + create(model: CmsModel): Promise>; + update(id: string, data: Partial): Promise>; + delete(id: string): Promise>; +} +``` + +**Use Case Pattern with CQS:** + +```typescript +// ✅ Command Use Case +interface ICreateModel { + execute(input: CreateModelInput): Promise>; +} + +// ✅ Query Use Case +interface IGetModel { + execute(input: GetModelInput): Promise>; +} + +// ✅ Query Use Case (list) +interface IListModels { + execute(input: ListModelsInput): Promise>; +} +``` + +**Benefits:** +1. ✅ Clear separation between reads and writes +2. ✅ Easier to reason about side effects +3. ✅ Better caching strategies (queries can be cached) +4. ✅ Simpler testing (queries are pure functions) +5. ✅ Follows api-core patterns + +**Note:** Storage operations (legacy) can remain unchanged. Only repositories and use cases follow CQS. + +**Entries Repository Example with CQS:** + +```typescript +interface IEntriesRepository { + // ✅ Queries + get(model: CmsModel, id: string): Promise>; + getLatestRevision(model: CmsModel, entryId: string): Promise>; + getPublishedRevision(model: CmsModel, entryId: string): Promise>; + list(model: CmsModel, params: ListParams): Promise>; + getRevisions(model: CmsModel, entryId: string): Promise>; + + // ✅ Commands + create(model: CmsModel, entry: CmsEntry): Promise>; + update(model: CmsModel, id: string, data: Partial): Promise>; + delete(model: CmsModel, id: string): Promise>; + publish(model: CmsModel, id: string): Promise>; + unpublish(model: CmsModel, id: string): Promise>; + moveToBin(model: CmsModel, id: string): Promise>; + restoreFromBin(model: CmsModel, id: string): Promise>; +} +``` + +--- + +#### 2.1 Content Models Use Cases + +**Example: CreateModel Use Case** + +```typescript +// features/contentModels/CreateModel/abstractions.ts +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ModelsRepository } from "../shared/abstractions.js"; +import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; + +export interface CreateModelInput { + name: string; + modelId: string; + group: string; + fields: any[]; // Field definitions + layout?: string[][]; + description?: string; +} + +export interface ICreateModelErrors { + validation: ModelValidationError; + alreadyExists: ModelAlreadyExistsError; +} + +type CreateModelError = ICreateModelErrors[keyof ICreateModelErrors] | ModelsRepository.Error; + +/** + * CreateModel follows CQS: + * This is a COMMAND - returns Result + * To get the created model, use GetModel query + */ +export interface ICreateModel { + execute(input: CreateModelInput): Promise>; +} + +export const CreateModel = createAbstraction("CreateModel"); + +export namespace CreateModel { + export type Interface = ICreateModel; + export type Error = CreateModelError; + export type Input = CreateModelInput; +} +``` + +```typescript +// features/contentModels/CreateModel/CreateModelUseCase.ts +import { createImplementation } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { CreateModel as UseCaseAbstraction } from "./abstractions.js"; +import { ModelsRepository } from "../shared/abstractions.js"; +import { EventPublisher } from "@webiny/api-core"; +import { ModelBeforeCreateEvent, ModelAfterCreateEvent } from "./events.js"; +import { ModelValidationError, ModelAlreadyExistsError } from "~/domains/contentModels/errors.js"; + +class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: ModelsRepository.Interface, + private eventPublisher: EventPublisher.Interface, + private validator: ModelValidator.Interface + ) {} + + async execute( + input: UseCaseAbstraction.Input + ): Promise> { + // 1. Validate input + const validation = await this.validator.validate(input); + if (validation.isFail()) { + return Result.fail(new ModelValidationError(validation.error.message)); + } + + // 2. Check if model already exists + const existing = await this.repository.get(input.modelId); + if (existing.isOk()) { + return Result.fail(new ModelAlreadyExistsError(input.modelId)); + } + + // 3. Create model object + const model: CmsModel = { + ...input, + tenant: getTenant().id, + locale: getLocale().code, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + createdBy: getIdentity() + }; + + // 4. Publish before event + await this.eventPublisher.publish( + new ModelBeforeCreateEvent({ model, input }) + ); + + // 5. Save to repository (CQS: returns void) + const result = await this.repository.create(model); + if (result.isFail()) { + return Result.fail(result.error); + } + + // 6. Publish after event + await this.eventPublisher.publish( + new ModelAfterCreateEvent({ model, input }) + ); + + // ✅ CQS: Command returns void + // Client can use GetModel query to retrieve created model + return Result.ok(); + } +} + +export const CreateModelUseCaseImpl = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: CreateModelUseCaseImpl, + dependencies: [ModelsRepository, EventPublisher, ModelValidator] +}); +``` + +#### 2.2 Content Entries Use Cases + +**Priority Order for Migration:** + +1. ✅ **CreateEntry** - Most fundamental operation +2. ✅ **GetEntry** - Single entry retrieval +3. ✅ **ListEntries** - Bulk retrieval with filtering +4. ✅ **UpdateEntry** - Entry modification +5. ✅ **PublishEntry** - Publishing workflow +6. ✅ **UnpublishEntry** - Unpublishing workflow +7. ✅ **DeleteEntry** - Soft delete to bin +8. ✅ **RestoreEntry** - Restore from bin +9. ✅ **CreateRevision** - Revision branching +10. ✅ **GetRevisions** - Revision history + +**IMPORTANT:** Existing use cases in `crud/contentEntry/useCases/` need **refactoring to new DI architecture**. Keep existing logic but adapt to proper abstractions and feature structure. + +### Current Architecture Issues + +1. **No DI abstractions** - Use cases don't use `createAbstraction` or `createImplementation` +2. **Manual composition** - Decorators manually composed in factory functions +3. **Events as decorators** - Events wrapped as decorators instead of being in use case +4. **Concrete dependencies** - Constructor takes concrete types, not abstractions + +### Migration Strategy: Refactor to New Architecture + +**Current Structure (Example: DeleteEntry):** +``` +crud/contentEntry/useCases/DeleteEntry/ +├── DeleteEntry.ts # Orchestrator +├── DeleteEntryOperation.ts # Base operation +├── DeleteEntryOperationWithEvents.ts # ❌ Events as decorator +├── DeleteEntrySecure.ts # ✅ Real decorator (authorization) +├── TransformEntryDelete.ts # ✅ Real decorator (transform) +└── index.ts # Manual factory +``` + +**Target Structure:** +``` +features/contentEntries/DeleteEntry/ +├── abstractions.ts # DI abstractions +├── DeleteEntryUseCase.ts # Use case with events inside ✅ +├── decorators/ +│ ├── DeleteEntrySecureDecorator.ts # Authorization (from Secure) +│ └── DeleteEntryTransformDecorator.ts # Transform (from Transform) +├── events.ts # Event class definitions +└── feature.ts # DI registration +``` + +### Refactoring Pattern + +**Step 1: Create Abstractions** +```typescript +// features/contentEntries/DeleteEntry/abstractions.ts +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsEntry } from "~/domains/contentEntries/CmsEntry.js"; +import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; + +export interface DeleteEntryInput { + model: CmsModel; + id: string; + options?: { force?: boolean }; +} + +export interface IDeleteEntry { + execute(input: DeleteEntryInput): Promise; +} + +export const DeleteEntry = createAbstraction("DeleteEntry"); + +export namespace DeleteEntry { + export type Interface = IDeleteEntry; + export type Input = DeleteEntryInput; +} +``` + +**Step 2: Refactor Use Case (Merge Operation + Events)** + +```typescript +// features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts +import { createImplementation } from "@webiny/feature/api"; +import { DeleteEntry as UseCaseAbstraction } from "./abstractions.js"; +import { EntriesRepository } from "../shared/abstractions.js"; +import { EventPublisher } from "@webiny/api-core"; +import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent } from "./events.js"; + +class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: EntriesRepository.Interface, + private eventPublisher: EventPublisher.Interface // ✅ Events in use case + ) {} + + async execute(input: UseCaseAbstraction.Input): Promise { + const { model, id, options } = input; + + // Get entry + const entry = await this.repository.getLatestRevision(model, id); + if (!entry && !options?.force) { + throw new NotFoundError(`Entry "${id}" was not found!`); + } + + // ✅ Publish BEFORE event (part of use case logic) + await this.eventPublisher.publish( + new EntryBeforeDeleteEvent({ model, entry, input }) + ); + + // Execute deletion + await this.repository.delete(model, { entry }); + + // ✅ Publish AFTER event (part of use case logic) + await this.eventPublisher.publish( + new EntryAfterDeleteEvent({ model, entry, input }) + ); + } +} + +export const DeleteEntryUseCaseImpl = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: DeleteEntryUseCaseImpl, + dependencies: [EntriesRepository, EventPublisher] // ✅ EventPublisher injected +}); +``` + +**Step 3: Refactor ONLY Real Decorators (Not Events)** + +```typescript +// features/contentEntries/DeleteEntry/decorators/DeleteEntrySecureDecorator.ts +import { createDecorator } from "@webiny/feature/api"; +import { DeleteEntry } from "../abstractions.js"; +import { AccessControl } from "~/crud/AccessControl/abstractions.js"; + +// ✅ Refactor DeleteEntrySecure - this IS a real decorator (cross-cutting concern) +class DeleteEntrySecureDecoratorImpl implements DeleteEntry.Interface { + constructor( + private accessControl: AccessControl.Interface, + private decoratee: DeleteEntry.Interface + ) {} + + async execute(input: DeleteEntry.Input): Promise { + // Authorization check + await this.accessControl.ensureCanDelete({ model: input.model }); + + // Delegate to use case + return this.decoratee.execute(input); + } +} + +export const DeleteEntrySecureDecorator = createDecorator({ + abstraction: DeleteEntry, + decorator: DeleteEntrySecureDecoratorImpl, + dependencies: [AccessControl] +}); +``` + +**Step 4: Feature Registration** +```typescript +// features/contentEntries/DeleteEntry/feature.ts +import { createFeature } from "@webiny/feature"; +import { DeleteEntryUseCaseImpl } from "./DeleteEntryUseCase.js"; +import { DeleteEntrySecureDecorator } from "./decorators/DeleteEntrySecureDecorator.js"; +import { DeleteEntryTransformDecorator } from "./decorators/DeleteEntryTransformDecorator.js"; + +export const DeleteEntryFeature = createFeature({ + name: "DeleteEntry", + register(container) { + // Register use case (with events inside) + container.register(DeleteEntryUseCaseImpl); + + // Register ONLY real decorators (not events) + container.registerDecorator(DeleteEntrySecureDecorator); + container.registerDecorator(DeleteEntryTransformDecorator); + } +}); +``` + +### Key Refactoring Rules + +1. ✅ **Events IN use case** - Not as decorators, directly in use case logic +2. ✅ **Keep real decorators** - Authorization, Transform, Validation are decorators +3. ✅ **Merge Operation + WithEvents** - Combine into single use case +4. ✅ **EventPublisher injected** - Use case depends on EventPublisher +5. ✅ **Keep existing logic** - Just restructure, don't change behavior +6. ❌ **No event decorators** - Remove `*WithEvents` pattern entirely + +--- + +### Phase 3: Event System Unification (Week 5) + +#### 3.1 Migrate from Pub/Sub Topics to DI Events + +**Current State:** +```typescript +// In CRUD files - pub/sub pattern +const onEntryBeforeCreate = createTopic("cms.onEntryBeforeCreate"); +await onEntryBeforeCreate.publish({ entry, model }); +``` + +**Target State:** +```typescript +// In use cases - DI-based events +await this.eventPublisher.publish( + new EntryBeforeCreateEvent({ entry, model }) +); +``` + +#### 3.2 Event Definitions + +```typescript +// domains/contentEntries/features/CreateEntry/events.ts +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core"; +import type { IEventHandler } from "@webiny/api-core"; +import type { CmsEntry, CmsModel, CreateEntryInput } from "~/types/index.js"; + +export interface EntryBeforeCreatePayload { + entry: CmsEntry; + model: CmsModel; + input: CreateEntryInput; +} + +export class EntryBeforeCreateEvent extends DomainEvent { + eventType = "entry.beforeCreate" as const; + + getHandlerAbstraction() { + return EntryBeforeCreateHandler; + } +} + +export const EntryBeforeCreateHandler = createAbstraction< + IEventHandler +>("EntryBeforeCreateHandler"); + +export namespace EntryBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeCreateEvent; +} + +// After event +export interface EntryAfterCreatePayload { + entry: CmsEntry; + model: CmsModel; + input: CreateEntryInput; +} + +export class EntryAfterCreateEvent extends DomainEvent { + eventType = "entry.afterCreate" as const; + + getHandlerAbstraction() { + return EntryAfterCreateHandler; + } +} + +export const EntryAfterCreateHandler = createAbstraction< + IEventHandler +>("EntryAfterCreateHandler"); + +export namespace EntryAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterCreateEvent; +} +``` + +#### 3.3 Migration Strategy for Events + +1. **Keep both systems temporarily** - Allow gradual migration +2. **Create bridge adapters** - Convert pub/sub to event handlers +3. **Deprecate pub/sub topics** - Mark as deprecated, log warnings +4. **Remove in next major version** - Clean removal path + +--- + +### Phase 4: Feature Registration (Week 6) + +#### 4.1 Feature Definitions + +```typescript +// domains/contentModels/features/CreateModel/feature.ts +import { createFeature } from "@webiny/feature"; +import { CreateModelUseCaseImpl } from "./CreateModelUseCase.js"; +import { CreateModelValidationDecorator } from "./decorators/ValidationDecorator.js"; +import { CreateModelAuthorizationDecorator } from "./decorators/AuthorizationDecorator.js"; + +export const CreateModelFeature = createFeature({ + name: "CreateModel", + register(container) { + // Register use case + container.register(CreateModelUseCaseImpl); + + // Register decorators + container.registerDecorator(CreateModelValidationDecorator); + container.registerDecorator(CreateModelAuthorizationDecorator); + } +}); +``` + +#### 4.2 Domain-Level Features + +```typescript +// domains/contentModels/feature.ts +import { createFeature } from "@webiny/feature"; +import { CreateModelFeature } from "./features/CreateModel/feature.js"; +import { UpdateModelFeature } from "./features/UpdateModel/feature.js"; +import { DeleteModelFeature } from "./features/DeleteModel/feature.js"; +import { GetModelFeature } from "./features/GetModel/feature.js"; +import { ListModelsFeature } from "./features/ListModels/feature.js"; +import { ModelsRepositoryImpl } from "./shared/ModelsRepository.js"; +import { ModelValidatorImpl } from "./shared/ModelValidator.js"; + +export const ContentModelsFeature = createFeature({ + name: "ContentModels", + register(container) { + // Register shared components + container.register(ModelsRepositoryImpl).inSingletonScope(); + container.register(ModelValidatorImpl).inSingletonScope(); + + // Register all model features + CreateModelFeature.register(container); + UpdateModelFeature.register(container); + DeleteModelFeature.register(container); + GetModelFeature.register(container); + ListModelsFeature.register(container); + } +}); +``` + +--- + +### Phase 5: Backward Compatibility Layer (Week 7) + +#### 5.1 Legacy Context Adapter + +Maintain backward compatibility with existing code that uses the old context API. + +```typescript +// legacy/adapters/LegacyContextAdapter.ts +import type { CmsModelContext, CmsGroupContext, CmsEntryContext } from "~/types/index.js"; +import { Container } from "@webiny/di"; +import { CreateModel } from "~/domains/contentModels/features/CreateModel/abstractions.js"; +import { GetModel } from "~/domains/contentModels/features/GetModel/abstractions.js"; +// ... other imports + +export class LegacyModelContextAdapter implements CmsModelContext { + constructor(private container: Container) {} + + async createModel(data: any) { + const useCase = this.container.resolve(CreateModel); + const result = await useCase.execute(data); + + if (result.isFail()) { + throw new Error(result.error.message); + } + + return result.value; + } + + async getModel(modelId: string) { + const useCase = this.container.resolve(GetModel); + const result = await useCase.execute({ modelId }); + + if (result.isFail()) { + throw new NotFoundError(result.error.message); + } + + return result.value; + } + + // ... implement all other methods +} +``` + +#### 5.2 Dual Registration + +```typescript +// context.ts (main context creation) +export const createHeadlessCmsContext = () => { + return new ContextPlugin(async context => { + const container = new Container(); + + // Register all features + ContentModelsFeature.register(container); + ContentModelGroupsFeature.register(container); + ContentEntriesFeature.register(container); + + // NEW: Direct access to use cases + context.cms.useCases = { + createModel: container.resolve(CreateModel), + getModel: container.resolve(GetModel), + // ... all other use cases + }; + + // LEGACY: Backward compatible CRUD API + context.cms.models = new LegacyModelContextAdapter(container); + context.cms.groups = new LegacyGroupContextAdapter(container); + context.cms.entries = new LegacyEntryContextAdapter(container); + }); +}; +``` + +--- + +## Implementation Checklist + +### Phase 1: Foundation ✅ +- [ ] Create domain folder structure +- [ ] Define shared abstractions for all domains +- [ ] Implement ModelsRepository (DB + plugin models) +- [ ] Implement GroupsRepository (DB + plugin groups) +- [ ] Implement EntriesRepository +- [ ] Create domain-specific error classes +- [ ] Create PluginModelsProvider abstraction +- [ ] Create PluginGroupsProvider abstraction + +### Phase 2: Use Cases ✅ +- [ ] **Content Models:** + - [ ] CreateModel use case + - [ ] UpdateModel use case + - [ ] DeleteModel use case + - [ ] GetModel use case + - [ ] ListModels use case +- [ ] **Content Model Groups:** + - [ ] CreateGroup use case + - [ ] UpdateGroup use case + - [ ] DeleteGroup use case + - [ ] GetGroup use case + - [ ] ListGroups use case +- [ ] **Content Entries:** + - [ ] CreateEntry use case + - [ ] UpdateEntry use case + - [ ] DeleteEntry use case (move to bin) + - [ ] RestoreEntry use case + - [ ] GetEntry use case + - [ ] ListEntries use case + - [ ] PublishEntry use case + - [ ] UnpublishEntry use case + - [ ] RepublishEntry use case + - [ ] CreateRevision use case + - [ ] GetRevisions use case + - [ ] DeleteRevision use case + - [ ] MoveEntry use case + +### Phase 3: Events ✅ +- [ ] Define all domain events +- [ ] Create event handler abstractions +- [ ] Migrate from pub/sub topics to EventPublisher +- [ ] Create bridge adapters for backward compatibility +- [ ] Update all use cases to publish events + +### Phase 4: Features ✅ +- [ ] Create feature definitions for all use cases +- [ ] Create domain-level feature aggregators +- [ ] Register features in DI container +- [ ] Add decorators for cross-cutting concerns + +### Phase 5: Compatibility ✅ +- [ ] Create LegacyContextAdapter +- [ ] Implement all legacy context methods +- [ ] Add deprecation warnings +- [ ] Write migration guide for consumers +- [ ] Update documentation + +### Phase 6: Testing & Validation ✅ +- [ ] Write unit tests for all use cases +- [ ] Write integration tests for repositories +- [ ] Test backward compatibility +- [ ] Performance testing +- [ ] Update existing tests + +--- + +## Repository Pattern Details + +### Key Innovation: Provider Pattern for Plugin Models + +Following `api-core` pattern: + +```typescript +// domains/contentModels/shared/abstractions.ts +export interface IPluginModelsProvider { + getModels(): Promise; +} + +export const PluginModelsProvider = createAbstraction( + "PluginModelsProvider" +); + +export namespace PluginModelsProvider { + export type Interface = IPluginModelsProvider; +} +``` + +```typescript +// domains/contentModels/shared/PluginModelsProvider.ts +class PluginModelsProviderImpl implements PluginModelsProvider.Interface { + constructor( + private pluginRegistry: PluginRegistry, + private tenantContext: TenantContext.Interface, + private localeContext: LocaleContext.Interface + ) {} + + async getModels(): Promise { + const tenant = this.tenantContext.getTenant(); + const locale = this.localeContext.getLocale(); + + const plugins = this.pluginRegistry.byType( + CmsModelPlugin.type + ); + + return plugins + .filter(plugin => { + const model = plugin.contentModel; + // Filter by tenant/locale if specified + if (model.tenant && model.tenant !== tenant.id) return false; + if (model.locale && model.locale !== locale.code) return false; + return true; + }) + .map(plugin => ({ + ...plugin.contentModel, + tenant: tenant.id, + locale: locale.code + })); + } +} +``` + +**Benefits:** +1. ✅ Single repository interface for consumers +2. ✅ Transparent handling of dual sources +3. ✅ Proper access control applied to both +4. ✅ Caching handled at repository level +5. ✅ Clear separation between code and DB models + +--- + +## Domain Event Examples + +### Content Model Events + +```typescript +// Model lifecycle +model.beforeCreate +model.afterCreate +model.beforeUpdate +model.afterUpdate +model.beforeDelete +model.afterDelete +model.createError +model.updateError +model.deleteError +``` + +### Content Entry Events + +```typescript +// Entry lifecycle +entry.beforeCreate +entry.afterCreate +entry.beforeUpdate +entry.afterUpdate +entry.beforeDelete +entry.afterDelete + +// Publishing +entry.beforePublish +entry.afterPublish +entry.beforeUnpublish +entry.afterUnpublish +entry.beforeRepublish +entry.afterRepublish + +// Revisions +entry.revision.beforeCreate +entry.revision.afterCreate +entry.revision.beforeDelete +entry.revision.afterDelete + +// Location +entry.beforeMove +entry.afterMove + +// Soft delete +entry.beforeMoveToBin +entry.afterMoveToBin +entry.beforeRestoreFromBin +entry.afterRestoreFromBin + +// Errors +entry.createError +entry.updateError +entry.deleteError +entry.publishError +entry.unpublishError +entry.republishError +``` + +--- + +## Migration Risks & Mitigations + +### Risk 1: Breaking Changes +**Mitigation:** Maintain backward compatibility layer for entire migration period. Deprecate gradually. + +### Risk 2: Performance Regression +**Mitigation:** +- Keep existing caching strategies +- Add performance benchmarks +- Monitor repository query patterns + +### Risk 3: Complex Entry Operations +**Mitigation:** +- Migrate simpler operations first (models, groups) +- Use existing use case structure as foundation +- Extensive testing for entry workflows + +### Risk 4: Plugin Compatibility +**Mitigation:** +- Keep plugin interfaces unchanged (out of scope) +- Provider pattern isolates plugin handling +- Test with real plugin implementations + +--- + +## Success Criteria + +1. ✅ All CRUD operations available as use cases +2. ✅ Repository pattern successfully unifies DB + plugin models +3. ✅ Event system migrated to DI-based handlers +4. ✅ Full backward compatibility maintained +5. ✅ Test coverage equivalent or better than current +6. ✅ Performance equivalent or better than current +7. ✅ Clear domain boundaries established +8. ✅ Documentation updated + +--- + +## Timeline + +**Total Estimated Time: 7-8 weeks** + +| Phase | Duration | Deliverables | +|-------|----------|--------------| +| Phase 1 | 2 weeks | Domain structure, repositories, abstractions | +| Phase 2 | 2 weeks | All use cases implemented | +| Phase 3 | 1 week | Event system migrated | +| Phase 4 | 1 week | Features registered | +| Phase 5 | 1 week | Backward compatibility | +| Phase 6 | 1-2 weeks | Testing, validation, documentation | + +--- + +## Next Steps + +1. **Review & Approve** this migration plan +2. **Create tracking issues** for each phase +3. **Set up feature branches** for parallel development +4. **Begin Phase 1** with domain structure and repositories +5. **Establish testing strategy** before use case implementation + +--- + +## Questions for Clarification + +1. Should we migrate all entry use cases, or prioritize specific ones? +2. Is there a specific release timeline we need to align with? +3. Should we keep legacy crud files indefinitely or plan removal? +4. Are there specific plugin implementations we need to test against? +5. Should we introduce any new capabilities during migration (or pure refactor)? From b620ed02899771f6d21fcad16b350dc8dd387673 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 10 Nov 2025 21:18:33 +0100 Subject: [PATCH 03/71] wip: migrate crud files to features --- ai-context/backend-developer-guide.md | 2 +- packages/api-headless-cms/MIGRATION_PLAN.md | 43 +- packages/api-headless-cms/src/context.ts | 148 +++--- .../src/crud/AccessControl/AccessControl.ts | 18 - .../src/crud/contentEntry.crud.ts | 159 ++---- .../entryDataFactories/createEntryData.ts | 6 - .../createUpdateEntryData.ts | 1 - .../src/crud/contentModel.crud.ts | 3 +- .../src/crud/contentModelGroup.crud.ts | 30 +- .../crud/contentModelGroup/beforeCreate.ts | 3 - .../crud/contentModelGroup/beforeDelete.ts | 3 +- .../listGroupsFromDatabase.ts | 6 +- .../api-headless-cms/src/crud/system.crud.ts | 157 ------ .../src/domains/contentEntries/errors.ts | 92 ++++ .../src/domains/contentModelGroups/errors.ts | 61 +++ .../src/domains/contentModels/errors.ts | 61 +++ .../contentEntries/ContentEntriesFeature.ts | 16 + .../CreateEntry/CreateEntryRepository.ts | 44 ++ .../CreateEntry/CreateEntryUseCase.ts | 110 ++++ .../CreateEntry/abstractions.ts | 54 ++ .../contentEntries/CreateEntry/events.ts | 58 +++ .../contentEntries/CreateEntry/feature.ts | 22 + .../contentEntries/CreateEntry/index.ts | 2 + .../GetRevisionByIdRepository.ts | 47 ++ .../GetRevisionById/GetRevisionByIdUseCase.ts | 27 + .../GetRevisionById/abstractions.ts | 52 ++ .../GetRevisionByIdNotDeletedDecorator.ts | 41 ++ .../contentEntries/GetRevisionById/feature.ts | 26 + .../contentEntries/GetRevisionById/index.ts | 1 + .../UpdateEntry/UpdateEntryRepository.ts | 44 ++ .../UpdateEntry/UpdateEntryUseCase.ts | 136 +++++ .../UpdateEntry/abstractions.ts | 69 +++ .../contentEntries/UpdateEntry/events.ts | 61 +++ .../contentEntries/UpdateEntry/feature.ts | 22 + .../contentEntries/UpdateEntry/index.ts | 2 + .../shared/EntriesRepository.ts | 489 ++++++++++++++++++ .../contentEntries/shared/abstractions.ts | 134 +++++ .../shared/GroupsRepository.ts | 143 +++++ .../shared/PluginGroupsProvider.ts | 35 ++ .../contentModelGroups/shared/abstractions.ts | 83 +++ .../contentModels/shared/ModelsRepository.ts | 141 +++++ .../shared/PluginModelsProvider.ts | 35 ++ .../contentModels/shared/abstractions.ts | 82 +++ .../src/features/shared/abstractions.ts | 34 ++ .../api-headless-cms/src/graphql/getSchema.ts | 2 - .../src/graphql/getSchema/generateCacheId.ts | 6 +- .../src/graphql/handleRequest.ts | 5 - .../api-headless-cms/src/graphql/index.ts | 3 +- .../api-headless-cms/src/graphql/system.ts | 74 --- .../src/legacy/abstractions.ts | 41 ++ .../api-headless-cms/src/types/modelGroup.ts | 4 - packages/api-headless-cms/src/types/types.ts | 77 +-- 52 files changed, 2421 insertions(+), 594 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/system.crud.ts create mode 100644 packages/api-headless-cms/src/domains/contentEntries/errors.ts create mode 100644 packages/api-headless-cms/src/domains/contentModelGroups/errors.ts create mode 100644 packages/api-headless-cms/src/domains/contentModels/errors.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts create mode 100644 packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/shared/abstractions.ts delete mode 100644 packages/api-headless-cms/src/graphql/system.ts create mode 100644 packages/api-headless-cms/src/legacy/abstractions.ts diff --git a/ai-context/backend-developer-guide.md b/ai-context/backend-developer-guide.md index 51130b914db..d27546fe5a8 100644 --- a/ai-context/backend-developer-guide.md +++ b/ai-context/backend-developer-guide.md @@ -234,7 +234,7 @@ Every feature should define domain-specific errors that extend `BaseError` from ### Error Definition Pattern -Create a `shared/errors.ts` file in your feature with domain-specific errors: +Create a `errors.ts` file in your feature with domain-specific errors. If using `data`, make sure there's a type defined and passed to base class generic: `BaseError`. ```typescript // features/apiKeys/shared/errors.ts diff --git a/packages/api-headless-cms/MIGRATION_PLAN.md b/packages/api-headless-cms/MIGRATION_PLAN.md index e31d2b408e2..9975e532f87 100644 --- a/packages/api-headless-cms/MIGRATION_PLAN.md +++ b/packages/api-headless-cms/MIGRATION_PLAN.md @@ -620,7 +620,7 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { } } -export const CreateModelUseCaseImpl = createImplementation({ +export const CreateModelUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: CreateModelUseCaseImpl, dependencies: [ModelsRepository, EventPublisher, ModelValidator] @@ -1010,13 +1010,6 @@ export const createHeadlessCmsContext = () => { ContentModelGroupsFeature.register(container); ContentEntriesFeature.register(container); - // NEW: Direct access to use cases - context.cms.useCases = { - createModel: container.resolve(CreateModel), - getModel: container.resolve(GetModel), - // ... all other use cases - }; - // LEGACY: Backward compatible CRUD API context.cms.models = new LegacyModelContextAdapter(container); context.cms.groups = new LegacyGroupContextAdapter(container); @@ -1041,31 +1034,31 @@ export const createHeadlessCmsContext = () => { ### Phase 2: Use Cases ✅ - [ ] **Content Models:** - - [ ] CreateModel use case - - [ ] UpdateModel use case - - [ ] DeleteModel use case + - [ ] CreateModel use case (with domain events and event handler abstractions) + - [ ] UpdateModel use case (with domain events and event handler abstractions) + - [ ] DeleteModel use case (with domain events and event handler abstractions) - [ ] GetModel use case - [ ] ListModels use case - [ ] **Content Model Groups:** - - [ ] CreateGroup use case - - [ ] UpdateGroup use case - - [ ] DeleteGroup use case + - [ ] CreateGroup use case (with domain events and event handler abstractions) + - [ ] UpdateGroup use case (with domain events and event handler abstractions) + - [ ] DeleteGroup use case (with domain events and event handler abstractions) - [ ] GetGroup use case - [ ] ListGroups use case - [ ] **Content Entries:** - - [ ] CreateEntry use case - - [ ] UpdateEntry use case - - [ ] DeleteEntry use case (move to bin) - - [ ] RestoreEntry use case + - [ ] CreateEntry use case (with domain events and event handler abstractions) + - [ ] UpdateEntry use case (with domain events and event handler abstractions) + - [ ] DeleteEntry use case (move to bin) (with domain events and event handler abstractions) + - [ ] RestoreEntry use case (with domain events and event handler abstractions) - [ ] GetEntry use case - [ ] ListEntries use case - - [ ] PublishEntry use case - - [ ] UnpublishEntry use case - - [ ] RepublishEntry use case - - [ ] CreateRevision use case - - [ ] GetRevisions use case - - [ ] DeleteRevision use case - - [ ] MoveEntry use case + - [ ] PublishEntry use case (with domain events and event handler abstractions) + - [ ] UnpublishEntry use case (with domain events and event handler abstractions) + - [ ] RepublishEntry use case (with domain events and event handler abstractions) + - [ ] CreateRevision use case (with domain events and event handler abstractions) + - [ ] GetRevisions use case + - [ ] DeleteRevision use case (with domain events and event handler abstractions) + - [ ] MoveEntry use case (with domain events and event handler abstractions) ### Phase 3: Events ✅ - [ ] Define all domain events diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index f51e704a451..8ac3be20408 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -6,7 +6,6 @@ import { processRequestBody } from "@webiny/handler-graphql"; import type { CmsParametersPluginResponse } from "~/plugins/CmsParametersPlugin.js"; import { CmsParametersPlugin } from "~/plugins/CmsParametersPlugin.js"; import { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import { createSystemCrud } from "~/crud/system.crud.js"; import { createModelGroupsCrud } from "~/crud/contentModelGroup.crud.js"; import { createModelsCrud } from "~/crud/contentModel.crud.js"; import { createContentEntryCrud } from "~/crud/contentEntry.crud.js"; @@ -17,6 +16,18 @@ import { createImportCrud } from "~/export/crud/importing.js"; import { getSchema } from "~/graphql/getSchema.js"; import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; import { CmsInstallerFeature } from "~/features/installer/feature.js"; +import { ContentEntriesFeature } from "~/features/contentEntries/ContentEntriesFeature.js"; +import { + StorageOperations, + AccessControl as AccessControlAbstraction, + CmsContext as CmsContextAbstraction +} from "~/features/shared/abstractions.js"; +import { + EntryFromStorageTransform, + EntryToStorageTransform, + PluginsContainer +} from "./legacy/abstractions.js"; +import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; const getParameters = async (context: CmsContext): Promise => { const plugins = context.plugins.byType(CmsParametersPlugin.type); @@ -76,7 +87,6 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { return getSchema({ context, getTenant, - getLocale, type }); }); @@ -94,78 +104,80 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { ) ); - await context.benchmark.measure("headlessCms.createContext", async () => { - await storageOperations.beforeInit(context); - - const accessControl = new AccessControl({ - getIdentity: () => context.security.getIdentity(), - getGroupsPermissions: () => - context.security.getPermissions("cms.contentModelGroup"), - getModelsPermissions: () => context.security.getPermissions("cms.contentModel"), - getEntriesPermissions: () => context.security.getPermissions("cms.contentEntry"), - listAllGroups: () => { - return context.security.withoutAuthorization(() => { - return context.cms.listGroups(); - }); - } - }); + await storageOperations.beforeInit(context); + + const accessControl = new AccessControl({ + getIdentity: () => context.security.getIdentity(), + getGroupsPermissions: () => + context.security.getPermissions("cms.contentModelGroup"), + getModelsPermissions: () => context.security.getPermissions("cms.contentModel"), + getEntriesPermissions: () => context.security.getPermissions("cms.contentEntry"), + listAllGroups: () => { + return context.security.withoutAuthorization(() => { + return context.cms.listGroups(); + }); + } + }); - context.cms = { - type, - locale: getLocale().code, + context.cms = { + type, + READ: type === "read", + PREVIEW: type === "preview", + MANAGE: type === "manage", + storageOperations, + accessControl, + getExecutableSchema, + ...createModelGroupsCrud({ + context, + getTenant, + getIdentity, + storageOperations, + accessControl + }), + ...createModelsCrud({ + context, getLocale, - READ: type === "read", - PREVIEW: type === "preview", - MANAGE: type === "manage", + getTenant, + getIdentity, storageOperations, - accessControl, - getExecutableSchema, - ...createSystemCrud({ - context, - getTenant, - getLocale, - getIdentity, - storageOperations - }), - ...createModelGroupsCrud({ - context, - getTenant, - getLocale, - getIdentity, - storageOperations, - accessControl - }), - ...createModelsCrud({ - context, - getLocale, - getTenant, - getIdentity, - storageOperations, - accessControl - }), - ...createContentEntryCrud({ - context, - getIdentity, - getTenant, - getLocale, - storageOperations, - accessControl - }), - export: { - ...createExportCrud(context) - }, - importing: { - ...createImportCrud(context) - } - }; - - if (!storageOperations.init) { - return; + accessControl + }), + ...createContentEntryCrud({ + context, + getIdentity, + getTenant, + getLocale, + storageOperations, + accessControl + }), + export: { + ...createExportCrud(context) + }, + importing: { + ...createImportCrud(context) } - await storageOperations.init(context); + }; - CmsInstallerFeature.register(context.container, context.cms); + // Register legacy dependencies + context.container.registerInstance(StorageOperations, storageOperations); + context.container.registerInstance(AccessControlAbstraction, accessControl); + context.container.registerInstance(CmsContextAbstraction, context); + context.container.registerInstance(PluginsContainer, context.plugins); + context.container.registerInstance(EntryToStorageTransform, (model, entry) => { + return entryToStorageTransform(context, model, entry); + }); + context.container.registerInstance(EntryFromStorageTransform, (model, entry) => { + return entryFromStorageTransform(context, model, entry); }); + + // Register features + CmsInstallerFeature.register(context.container, context.cms); + ContentEntriesFeature.register(context.container); + + if (!storageOperations.init) { + return; + } + await storageOperations.init(context); }); plugin.name = "cms.createContext"; diff --git a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts index 4df39961e2a..accdb8b5b1a 100644 --- a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts +++ b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts @@ -176,24 +176,6 @@ export class AccessControl { }); } - if (groupsPermissions.groups) { - if ("group" in params) { - const { group } = params; - if (!group) { - continue; - } - - const { groups } = groupsPermissions; - if (!Array.isArray(groups[group.locale])) { - continue; - } - - if (!groups[group.locale].includes(group.id)) { - continue; - } - } - } - acl.push({ rwd: groupsPermissions.rwd as string, canAccessNonOwned: true, diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index f78c73ec81c..54c8955cce8 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -67,12 +67,10 @@ import { getSearchableFields } from "./contentEntry/searchableFields.js"; import { filterAsync } from "~/utils/filterAsync.js"; import { isEntryLevelEntryMetaField, pickEntryMetaFields } from "~/constants.js"; import { - createEntryData, createEntryRevisionFromData, createPublishEntryData, createRepublishEntryData, createUnpublishEntryData, - createUpdateEntryData, mapAndCleanUpdatedInputData } from "./contentEntry/entryDataFactories/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; @@ -94,6 +92,8 @@ import type { GenericRecord } from "@webiny/api/types.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; +import { CreateEntryUseCase } from "~/features/contentEntries/CreateEntry/index.js"; +import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index.js"; interface CreateContentEntryCrudParams { storageOperations: HeadlessCmsStorageOperations; @@ -410,62 +410,33 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm rawInput: CreateCmsEntryInput, options?: CreateCmsEntryOptionsInput ): Promise> => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); - - const { entry, input } = await createEntryData({ - context, - model, - options, - rawInput, - getLocale, - getTenant, - getIdentity: getSecurityIdentity, - accessControl - }); - - await accessControl.ensureCanAccessEntry({ model, entry, rwd: "w" }); + // Delegate to new CreateEntry use case + const useCase = context.container.resolve(CreateEntryUseCase); + const result = await useCase.execute(model, rawInput, options); - let storageEntry: CmsStorageEntry | null = null; - try { - await onEntryBeforeCreate.publish({ - entry, - input, - model - }); - - storageEntry = await entryToStorageTransform(context, model, entry); - - const result = await storageOperations.entries.create(model, { - entry, - storageEntry - }); - - await onEntryAfterCreate.publish({ - entry, - storageEntry: result, - model, - input - }); - - return entry as CmsEntry; - } catch (ex) { + if (result.isFail()) { + // Publish error event for backward compatibility await onEntryCreateError.publish({ - error: ex, - entry, + error: result.error, + entry: null as any, model, - input + input: rawInput }); + + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - ex.message || "Could not create content entry.", - ex.code || "CREATE_ENTRY_ERROR", - ex.data || { - error: ex, - input, - entry, - storageEntry + error.message || "Could not create content entry.", + error.code || "CREATE_ENTRY_ERROR", + { + error, + input: rawInput, + model } ); } + + return result.value as CmsEntry; }; const createEntryRevisionFrom: CmsEntryContext["createEntryRevisionFrom"] = async ( model, @@ -573,85 +544,33 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm metaInput?: GenericRecord, options?: UpdateCmsEntryOptionsInput ): Promise> => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); - - /** - * The entry we are going to update. - */ - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { id }); - - if (!originalStorageEntry) { - throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); - } + // Delegate to new UpdateEntry use case + const useCase = context.container.resolve(UpdateEntryUseCase); + const result = await useCase.execute(model, id, rawInput, metaInput, options); - if (originalStorageEntry.locked) { - throw new WebinyError( - `Cannot update entry because it's locked.`, - "CONTENT_ENTRY_UPDATE_ERROR" - ); - } - - const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); - - const { entry, input } = await createUpdateEntryData({ - model, - rawInput, - options, - context, - getIdentity: getSecurityIdentity, - getTenant, - getLocale, - originalEntry, - metaInput - }); - - await accessControl.ensureCanAccessEntry({ model, entry, rwd: "w" }); - - let storageEntry: CmsStorageEntry | null = null; - - try { - await onEntryBeforeUpdate.publish({ - entry, - model, - input, - original: originalEntry - }); - - storageEntry = await entryToStorageTransform(context, model, entry); - - const result = await storageOperations.entries.update(model, { - entry, - storageEntry - }); - - await onEntryAfterUpdate.publish({ - entry, - storageEntry: result, - model, - input, - original: originalEntry - }); - - return entry as CmsEntry; - } catch (ex) { + if (result.isFail()) { + // Publish error event for backward compatibility await onEntryUpdateError.publish({ - entry, + error: result.error, + entry: null as any, model, - input, - error: ex + input: rawInput }); + + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - ex.message || "Could not update existing entry.", - ex.code || "UPDATE_ERROR", + error.message || "Could not update existing entry.", + error.code || "UPDATE_ERROR", { - error: ex, - entry, - storageEntry, - originalEntry, - input + error, + input: rawInput, + model } ); } + + return result.value as CmsEntry; }; const validateEntry: CmsEntryContext["validateEntry"] = async (model, id, inputData) => { diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts index 8f968011d5b..992b7a17eb7 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts @@ -18,7 +18,6 @@ import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; import { getState } from "./state.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; type DefaultValue = boolean | number | string | null; @@ -29,7 +28,6 @@ interface CreateEntryDataParams { context: CmsContext; getIdentity: () => SecurityIdentity; getTenant: () => Tenant; - getLocale: () => I18NLocale; accessControl: AccessControl; } @@ -39,7 +37,6 @@ export const createEntryData = async ({ options, context, getIdentity: getSecurityIdentity, - getLocale, getTenant, accessControl }: CreateEntryDataParams): Promise<{ @@ -62,8 +59,6 @@ export const createEntryData = async ({ validateEntries: true }); - const locale = getLocale(); - const { id, entryId, version } = createEntryId(rawInput); /** @@ -138,7 +133,6 @@ export const createEntryData = async ({ entryId, id, modelId: model.modelId, - locale: locale.code, /** * Entry-level meta fields. 👇 diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts index 7e7911470d4..b4e81783818 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts @@ -26,7 +26,6 @@ interface CreateEntryRevisionFromDataParams { context: CmsContext; getIdentity: () => SecurityIdentity; getTenant: () => Tenant; - getLocale: () => I18NLocale; originalEntry: CmsEntry; } diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 7860ce03c3d..2ea9215f716 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -531,8 +531,7 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex */ const group = await context.cms.storageOperations.groups.get({ id: data.group, - tenant: original.tenant, - locale: locale.code + tenant: original.tenant }); if (!group) { throw new NotFoundError(`There is no group "${data.group}".`); diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index 0b9f5c0e3e6..54b23d67cf5 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -30,12 +30,10 @@ import { createCacheKey, createMemoryCache } from "~/utils/index.js"; import { listGroupsFromDatabase } from "~/crud/contentModelGroup/listGroupsFromDatabase.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; export interface CreateModelGroupsCrudParams { getTenant: () => Tenant; - getLocale: () => I18NLocale; storageOperations: HeadlessCmsStorageOperations; accessControl: AccessControl; context: CmsContext; @@ -43,7 +41,7 @@ export interface CreateModelGroupsCrudParams { } export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsGroupContext => { - const { getTenant, getIdentity, getLocale, storageOperations, accessControl, context } = params; + const { getTenant, getIdentity, storageOperations, accessControl, context } = params; const filterGroup = async (group?: CmsGroup) => { if (!group) { @@ -62,12 +60,11 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG listFilteredDatabaseGroupsCache.clear(); }; - const fetchPluginGroups = (tenant: string, locale: string): Promise => { + const fetchPluginGroups = (tenant: string): Promise => { const pluginGroups = context.plugins.byType(CmsGroupPlugin.type); const cacheKey = createCacheKey({ tenant, - locale, identity: context.security.isAuthorizationEnabled() ? getIdentity()?.id : undefined, groups: pluginGroups .map(({ contentModelGroup: group }) => { @@ -83,11 +80,9 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG * If it does not have tenant or locale define, it is for every locale and tenant */ .filter(plugin => { - const { tenant: t, locale: l } = plugin.contentModelGroup; + const { tenant: t } = plugin.contentModelGroup; if (t && t !== tenant) { return false; - } else if (l && l !== locale) { - return false; } return true; }) @@ -95,7 +90,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG return { ...plugin.contentModelGroup, tenant, - locale, webinyVersion: context.WEBINY_VERSION }; }); @@ -103,22 +97,20 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG }); }; - const fetchGroups = async (tenant: string, locale: string) => { - const pluginGroups = await fetchPluginGroups(tenant, locale); + const fetchGroups = async (tenant: string) => { + const pluginGroups = await fetchPluginGroups(tenant); /** * Maybe we can cache based on permissions, not the identity id? * * TODO: @adrian please check if possible. */ const cacheKey = createCacheKey({ - tenant, - locale + tenant }); const databaseGroups = await listDatabaseGroupsCache.getOrSet(cacheKey, async () => { return await listGroupsFromDatabase({ storageOperations, - tenant, - locale + tenant }); }); const filteredCacheKey = createCacheKey({ @@ -182,7 +174,7 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG await accessControl.ensureCanAccessGroup(); const groups = await context.security.withoutAuthorization(async () => { - return fetchGroups(getTenant().id, getLocale().code); + return fetchGroups(getTenant().id); }); const group = groups.find(group => group.id === id); if (!group) { @@ -197,11 +189,11 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG const listGroups: CmsGroupContext["listGroups"] = async params => { const { where } = params || {}; - const { tenant, locale } = where || {}; + const { tenant } = where || {}; await accessControl.ensureCanAccessGroup(); - return fetchGroups(tenant || getTenant().id, locale || getLocale().code); + return fetchGroups(tenant || getTenant().id); }; const createGroup: CmsGroupContext["createGroup"] = async input => { @@ -221,7 +213,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG ...data, id, tenant: getTenant().id, - locale: getLocale().code, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), createdBy: { @@ -291,7 +282,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG const group: CmsGroup = { ...original, ...data, - locale: getLocale().code, tenant: getTenant().id, savedOn: new Date().toISOString() }; diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts b/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts index f86444e7553..3b2018dd946 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts @@ -24,7 +24,6 @@ export const assignBeforeGroupCreate = (params: AssignBeforeGroupCreateParams) = const groups = await storageOperations.groups.list({ where: { tenant: group.tenant, - locale: group.locale, id: group.id } }); @@ -40,7 +39,6 @@ export const assignBeforeGroupCreate = (params: AssignBeforeGroupCreateParams) = const groups = await storageOperations.groups.list({ where: { tenant: group.tenant, - locale: group.locale, slug: group.slug } }); @@ -55,7 +53,6 @@ export const assignBeforeGroupCreate = (params: AssignBeforeGroupCreateParams) = const groups = await storageOperations.groups.list({ where: { tenant: group.tenant, - locale: group.locale, slug } }); diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts b/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts index bb72bce8a2e..fa5997fa70a 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts @@ -28,8 +28,7 @@ export const assignBeforeGroupDelete = (params: AssignBeforeGroupDeleteParams) = const models = await storageOperations.models.list({ where: { - tenant: group.tenant, - locale: group.locale + tenant: group.tenant } }); const items = models.filter(model => { diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts b/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts index 139d8207ce2..cf3af5def63 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts @@ -3,16 +3,14 @@ import type { HeadlessCmsStorageOperations } from "~/types/index.js"; interface Params { storageOperations: HeadlessCmsStorageOperations; tenant: string; - locale: string; } export const listGroupsFromDatabase = async (params: Params) => { - const { storageOperations, tenant, locale } = params; + const { storageOperations, tenant } = params; return await storageOperations.groups.list({ where: { - tenant, - locale + tenant } }); }; diff --git a/packages/api-headless-cms/src/crud/system.crud.ts b/packages/api-headless-cms/src/crud/system.crud.ts deleted file mode 100644 index 682461f300c..00000000000 --- a/packages/api-headless-cms/src/crud/system.crud.ts +++ /dev/null @@ -1,157 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { - OnSystemAfterInstallTopicParams, - OnSystemBeforeInstallTopicParams, - CmsContext, - CmsSystem, - CmsSystemContext, - HeadlessCmsStorageOperations, - OnSystemInstallErrorTopicParams -} from "~/types/index.js"; -import { createTopic } from "@webiny/pubsub"; -import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; - -const initialContentModelGroup = { - name: "Ungrouped", - slug: "ungrouped", - description: "A generic content model group", - icon: "fas/star" -}; - -interface CreateSystemCrudParams { - getTenant: () => Tenant; - getLocale: () => I18NLocale; - storageOperations: HeadlessCmsStorageOperations; - context: CmsContext; - getIdentity: () => SecurityIdentity; -} -export const createSystemCrud = (params: CreateSystemCrudParams): CmsSystemContext => { - const { getTenant, getLocale, storageOperations, context, getIdentity } = params; - - const onSystemBeforeInstall = createTopic( - "cms.onSystemBeforeInstall" - ); - const onSystemAfterInstall = createTopic( - "cms.onSystemAfterInstall" - ); - - const onSystemInstallError = createTopic( - "cms.onSystemInstallError" - ); - - const getVersion = async () => { - if (!getTenant()) { - return null; - } - - const system = await storageOperations.system.get({ - tenant: getTenant().id - }); - - return system?.version || null; - }; - - const setVersion = async (version: string) => { - const original = await storageOperations.system.get({ - tenant: getTenant().id - }); - const system: CmsSystem = { - ...(original || {}), - version, - tenant: getTenant().id - }; - if (!original) { - await storageOperations.system.create({ - system - }); - return; - } - await storageOperations.system.update({ - system - }); - }; - - return { - /** - * Lifecycle Events. - */ - onSystemBeforeInstall, - onSystemAfterInstall, - onSystemInstallError, - getSystemVersion: getVersion, - setSystemVersion: setVersion, - installSystem: async (): Promise => { - const identity = getIdentity(); - if (!identity) { - throw new NotAuthorizedError(); - } - - const version = await getVersion(); - if (version) { - return; - } - try { - /** - * First trigger before install event. - */ - await onSystemBeforeInstall.publish({ - tenant: getTenant().id, - locale: getLocale().code - }); - - /** - * Add default content model group. - */ - try { - await context.cms.createGroup(initialContentModelGroup); - } catch (ex) { - throw new WebinyError( - ex.message, - "CMS_INSTALLATION_CONTENT_MODEL_GROUP_ERROR", - { - group: initialContentModelGroup - } - ); - } - - const system: CmsSystem = { - version: context.WEBINY_VERSION, - tenant: getTenant().id - }; - /** - * We need to create the system data. - */ - await storageOperations.system.create({ - system - }); - /** - * And trigger after install event. - */ - await onSystemAfterInstall.publish({ - tenant: getTenant().id, - locale: getLocale().code - }); - - /** - * TODO: Implement this event in a better way, because this is easy to overlook and forget to update - * when new apps are added and have their own installers. - * - * Headless CMS is the last app that has an installer. Once its installation is finished, - * we need to notify the system that tenant is now ready to use, because many external plugins - * insert initial tenant data into various apps, copy data from other tenants, etc. - */ - // await context.tenancy.onTenantAfterInstall.publish({}); - } catch (ex) { - await onSystemInstallError.publish({ - error: ex, - tenant: getTenant().id, - locale: getLocale().code - }); - throw new WebinyError(ex.message, ex.code, ex.data); - } - } - }; -}; diff --git a/packages/api-headless-cms/src/domains/contentEntries/errors.ts b/packages/api-headless-cms/src/domains/contentEntries/errors.ts new file mode 100644 index 00000000000..24ec1baac6a --- /dev/null +++ b/packages/api-headless-cms/src/domains/contentEntries/errors.ts @@ -0,0 +1,92 @@ +import { BaseError } from "@webiny/feature/api"; + +export class EntryNotFoundError extends BaseError { + override readonly code = "ENTRY_NOT_FOUND" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" was not found!` + }); + } +} + +export class EntryNotAccessibleError extends BaseError { + override readonly code = "ENTRY_NOT_ACCESSIBLE" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" was not found!` + }); + } +} + + +export class EntryStorageError extends BaseError { + override readonly code = "ENTRY_STORAGE_ERROR" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class EntryValidationError extends BaseError { + override readonly code = "ENTRY_VALIDATION_ERROR" as const; + + constructor(message: string) { + super({ + message + }); + } +} + +export class EntryAlreadyPublishedError extends BaseError { + override readonly code = "ENTRY_ALREADY_PUBLISHED" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" is already published!` + }); + } +} + +export class EntryNotPublishedError extends BaseError { + override readonly code = "ENTRY_NOT_PUBLISHED" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" is not published!` + }); + } +} + +export class EntryInBinError extends BaseError { + override readonly code = "ENTRY_IN_BIN" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" is in bin!` + }); + } +} + +export class EntryNotInBinError extends BaseError { + override readonly code = "ENTRY_NOT_IN_BIN" as const; + + constructor(id: string) { + super({ + message: `Entry "${id}" is not in bin!` + }); + } +} + +export class EntryLockedError extends BaseError { + override readonly code = "ENTRY_LOCKED" as const; + + constructor(id: string) { + super({ + message: `Cannot update entry "${id}" because it's locked.` + }); + } +} diff --git a/packages/api-headless-cms/src/domains/contentModelGroups/errors.ts b/packages/api-headless-cms/src/domains/contentModelGroups/errors.ts new file mode 100644 index 00000000000..de06f991283 --- /dev/null +++ b/packages/api-headless-cms/src/domains/contentModelGroups/errors.ts @@ -0,0 +1,61 @@ +import { BaseError } from "@webiny/feature/api"; + +export class GroupNotFoundError extends BaseError { + override readonly code = "GROUP_NOT_FOUND" as const; + + constructor(groupId: string) { + super({ + message: `Group "${groupId}" was not found!` + }); + } +} + +export class GroupAlreadyExistsError extends BaseError { + override readonly code = "GROUP_ALREADY_EXISTS" as const; + + constructor(groupId: string) { + super({ + message: `Group "${groupId}" already exists!` + }); + } +} + +export class GroupStorageError extends BaseError { + override readonly code = "GROUP_STORAGE_ERROR" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class GroupValidationError extends BaseError { + override readonly code = "GROUP_VALIDATION_ERROR" as const; + + constructor(message: string) { + super({ + message + }); + } +} + +export class GroupCannotUpdateCodeDefinedError extends BaseError { + override readonly code = "GROUP_CANNOT_UPDATE_CODE_DEFINED" as const; + + constructor(groupId: string) { + super({ + message: `Cannot update code-defined group "${groupId}"` + }); + } +} + +export class GroupCannotDeleteCodeDefinedError extends BaseError { + override readonly code = "GROUP_CANNOT_DELETE_CODE_DEFINED" as const; + + constructor(groupId: string) { + super({ + message: `Cannot delete code-defined group "${groupId}"` + }); + } +} diff --git a/packages/api-headless-cms/src/domains/contentModels/errors.ts b/packages/api-headless-cms/src/domains/contentModels/errors.ts new file mode 100644 index 00000000000..0ef93745b18 --- /dev/null +++ b/packages/api-headless-cms/src/domains/contentModels/errors.ts @@ -0,0 +1,61 @@ +import { BaseError } from "@webiny/feature/api"; + +export class ModelNotFoundError extends BaseError { + override readonly code = "MODEL_NOT_FOUND" as const; + + constructor(modelId: string) { + super({ + message: `Model "${modelId}" was not found!` + }); + } +} + +export class ModelAlreadyExistsError extends BaseError { + override readonly code = "MODEL_ALREADY_EXISTS" as const; + + constructor(modelId: string) { + super({ + message: `Model "${modelId}" already exists!` + }); + } +} + +export class ModelStorageError extends BaseError { + override readonly code = "MODEL_STORAGE_ERROR" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class ModelValidationError extends BaseError { + override readonly code = "MODEL_VALIDATION_ERROR" as const; + + constructor(message: string) { + super({ + message + }); + } +} + +export class ModelCannotUpdateCodeDefinedError extends BaseError { + override readonly code = "MODEL_CANNOT_UPDATE_CODE_DEFINED" as const; + + constructor(modelId: string) { + super({ + message: `Cannot update model "${modelId}" defined via code` + }); + } +} + +export class ModelCannotDeleteCodeDefinedError extends BaseError { + override readonly code = "MODEL_CANNOT_DELETE_CODE_DEFINED" as const; + + constructor(modelId: string) { + super({ + message: `Cannot delete code-defined model "${modelId}"` + }); + } +} diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts new file mode 100644 index 00000000000..f11ad441751 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -0,0 +1,16 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateEntryFeature } from "./CreateEntry/feature.js"; +import { UpdateEntryFeature } from "./UpdateEntry/feature.js"; +import { GetRevisionByIdFeature } from "./GetRevisionById/feature.js"; + +export const ContentEntriesFeature = createFeature({ + name: "ContentEntries", + register(container) { + // Query features + GetRevisionByIdFeature.register(container); + + // Command features + CreateEntryFeature.register(container); + UpdateEntryFeature.register(container); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts new file mode 100644 index 00000000000..b77351c4300 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { CreateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; + +/** + * CreateEntryRepository - Handles persistence of new entries. + * Transforms domain entry to storage format and persists it. + */ +class CreateEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform domain entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Persist to storage + await this.storageOperations.entries.create(model, { + entry, + storageEntry + }); + + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const CreateEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: CreateEntryRepositoryImpl, + dependencies: [EntryToStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts new file mode 100644 index 00000000000..89565715c79 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts @@ -0,0 +1,110 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { CreateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CreateEntryRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { EntryBeforeCreateEvent, EntryAfterCreateEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { + CmsEntry, + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { createEntryData } from "~/crud/contentEntry/entryDataFactories/createEntryData.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; + +/** + * CreateEntryUseCase - Orchestrates entry creation. + * + * Responsibilities: + * - Transform raw input to domain entry + * - Apply access control + * - Publish domain events + * - Delegate persistence to repository + */ +class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: CreateEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + rawInput: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise> { + // Check initial access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + try { + // Transform raw input to domain entry + const { entry, input } = await createEntryData({ + model, + rawInput, + options, + context: this.cmsContext, + getIdentity: () => this.identityContext.getIdentity(), + getTenant: () => this.tenantContext.getTenant(), + accessControl: this.accessControl + }); + + // Apply access control on the created entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(new NotAuthorizedError()); + } + + // Publish before event + await this.eventPublisher.publish(new EntryBeforeCreateEvent({ entry, input, model })); + + // Persist entry + const result = await this.repository.execute(model, entry); + if (result.isFail()) { + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterCreateEvent({ + entry, + input, + model + }) + ); + + return Result.ok(entry); + } catch (error) { + // Handle errors from createEntryData or other operations + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const CreateEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: CreateEntryUseCaseImpl, + dependencies: [ + EventPublisher, + CreateEntryRepository, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts new file mode 100644 index 00000000000..d293bb7b737 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts @@ -0,0 +1,54 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; +import type { EntryStorageError, EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * CreateEntry Use Case + */ +export interface ICreateEntryUseCase { + execute( + model: CmsModel, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise>; +} + +export interface ICreateEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + validation: EntryValidationError; + repository: RepositoryError; +} + +type UseCaseError = ICreateEntryUseCaseErrors[keyof ICreateEntryUseCaseErrors]; + +export const CreateEntryUseCase = createAbstraction("CreateEntryUseCase"); + +export namespace CreateEntryUseCase { + export type Interface = ICreateEntryUseCase; + export type Error = UseCaseError; +} + +/** + * CreateEntryRepository - Persists a new entry to storage. + * Takes a domain CmsEntry object and stores it. + */ +export interface ICreateEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface ICreateEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = ICreateEntryRepositoryErrors[keyof ICreateEntryRepositoryErrors]; + +export const CreateEntryRepository = createAbstraction( + "CreateEntryRepository" +); + +export namespace CreateEntryRepository { + export type Interface = ICreateEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts new file mode 100644 index 00000000000..934c40a344d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts @@ -0,0 +1,58 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsEntry, CmsModel, CreateCmsEntryInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface EntryBeforeCreatePayload { + entry: CmsEntry; + input: CreateCmsEntryInput; + model: CmsModel; +} + +export interface EntryAfterCreatePayload { + entry: CmsEntry; + input: CreateCmsEntryInput; + model: CmsModel; +} + +/** + * EntryBeforeCreateEvent - Published before creating an entry + */ +export class EntryBeforeCreateEvent extends DomainEvent { + eventType = "entry.beforeCreate" as const; + + getHandlerAbstraction() { + return EntryBeforeCreateHandler; + } +} + +export const EntryBeforeCreateHandler = createAbstraction>( + "EntryBeforeCreateHandler" +); + +export namespace EntryBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeCreateEvent; +} + +/** + * EntryAfterCreateEvent - Published after creating an entry + */ +export class EntryAfterCreateEvent extends DomainEvent { + eventType = "entry.afterCreate" as const; + + getHandlerAbstraction() { + return EntryAfterCreateHandler; + } +} + +export const EntryAfterCreateHandler = + createAbstraction>("EntryAfterCreateHandler"); + +export namespace EntryAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterCreateEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts new file mode 100644 index 00000000000..0b0d7d5ed94 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts @@ -0,0 +1,22 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "./CreateEntryUseCase.js"; +import { CreateEntryRepository } from "./CreateEntryRepository.js"; + +/** + * CreateEntry Feature + * + * Provides complete functionality for creating new content entries: + * - Use case for orchestration + * - Repository for persistence + * - Events for extensibility + */ +export const CreateEntryFeature = createFeature({ + name: "CreateEntry", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(CreateEntryUseCase); + + // Register repository in singleton scope (shared instance) + container.register(CreateEntryRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts new file mode 100644 index 00000000000..0c57ba11c86 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetRevisionByIdRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError, EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetRevisionByIdRepository - Fetches entry revision from storage and transforms it. + * Returns entry or fails with EntryNotFoundError if not found. + */ +class GetRevisionByIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + try { + // Fetch from storage + const storageEntry = await this.storageOperations.entries.getRevisionById(model, { + id + }); + + if (!storageEntry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Transform storage entry to domain entry + const entry = await this.entryFromStorageTransform(model, storageEntry); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetRevisionByIdRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetRevisionByIdRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts new file mode 100644 index 00000000000..6eab3f9ffd4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts @@ -0,0 +1,27 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetRevisionByIdUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetRevisionByIdRepository } from "./abstractions.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; + +/** + * GetRevisionByIdUseCase - Fetches a specific entry revision. + * + * This is a simple query use case that delegates to the repository. + */ +class GetRevisionByIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private repository: GetRevisionByIdRepository.Interface) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + return this.repository.execute(model, id); + } +} + +export const GetRevisionByIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetRevisionByIdUseCaseImpl, + dependencies: [GetRevisionByIdRepository] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts new file mode 100644 index 00000000000..7104bccf9ad --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts @@ -0,0 +1,52 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * GetRevisionById Use Case - Fetches a specific entry revision by ID. + * Returns the entry or fails with EntryNotFoundError if not found or deleted. + */ +export interface IGetRevisionByIdUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IGetRevisionByIdUseCaseErrors { + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IGetRevisionByIdUseCaseErrors[keyof IGetRevisionByIdUseCaseErrors]; + +export const GetRevisionByIdUseCase = createAbstraction( + "GetRevisionByIdUseCase" +); + +export namespace GetRevisionByIdUseCase { + export type Interface = IGetRevisionByIdUseCase; + export type Error = UseCaseError; +} + +/** + * GetRevisionByIdRepository - Fetches entry revision from storage. + * Returns the entry or fails with EntryNotFoundError if not found. + */ +export interface IGetRevisionByIdRepository { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IGetRevisionByIdRepositoryErrors { + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type RepositoryError = IGetRevisionByIdRepositoryErrors[keyof IGetRevisionByIdRepositoryErrors]; + +export const GetRevisionByIdRepository = createAbstraction( + "GetRevisionByIdRepository" +); + +export namespace GetRevisionByIdRepository { + export type Interface = IGetRevisionByIdRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts new file mode 100644 index 00000000000..a3dc6b24a90 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts @@ -0,0 +1,41 @@ +import { createDecorator } from "@webiny/feature/api"; +import { GetRevisionByIdUseCase } from "../abstractions.js"; +import { Result } from "@webiny/feature/api"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; + +/** + * GetRevisionByIdNotDeletedDecorator - Filters out deleted entries. + * + * This decorator wraps the GetRevisionByIdUseCase and returns EntryNotFoundError + * if the entry is marked as deleted (wbyDeleted flag). + */ +class GetRevisionByIdNotDeletedDecorator implements GetRevisionByIdUseCase.Interface { + constructor(private decoratee: GetRevisionByIdUseCase.Interface) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + const result = await this.decoratee.execute(model, id); + + if (result.isFail()) { + return result; + } + + const entry = result.value; + + // Filter out deleted entries + if (entry.wbyDeleted) { + return Result.fail(new EntryNotFoundError(id)); + } + + return Result.ok(entry); + } +} + +export const GetRevisionByIdNotDeleted = createDecorator({ + abstraction: GetRevisionByIdUseCase, + decorator: GetRevisionByIdNotDeletedDecorator, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts new file mode 100644 index 00000000000..f8d68af5adc --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts @@ -0,0 +1,26 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetRevisionByIdUseCase } from "./GetRevisionByIdUseCase.js"; +import { GetRevisionByIdRepository } from "./GetRevisionByIdRepository.js"; +import { GetRevisionByIdNotDeleted } from "./decorators/GetRevisionByIdNotDeletedDecorator.js"; + +/** + * GetRevisionById Feature + * + * Provides functionality for fetching entry revisions by ID: + * - Use case for orchestration + * - Repository for data fetching + * - NotDeleted decorator to filter deleted entries + */ +export const GetRevisionByIdFeature = createFeature({ + name: "GetRevisionById", + register(container) { + // Register repository in singleton scope + container.register(GetRevisionByIdRepository).inSingletonScope(); + + // Register use case in transient scope + container.register(GetRevisionByIdUseCase); + + // Register decorator (filters deleted entries) + container.register(GetRevisionByIdNotDeleted); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts new file mode 100644 index 00000000000..8ade5674645 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts @@ -0,0 +1 @@ +export { GetRevisionByIdUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts new file mode 100644 index 00000000000..5bcada5f6e3 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { UpdateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; + +/** + * UpdateEntryRepository - Handles persistence of entry updates. + * Transforms domain entry to storage format and persists changes. + */ +class UpdateEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform domain entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Persist to storage + await this.storageOperations.entries.update(model, { + entry, + storageEntry + }); + + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const UpdateEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: UpdateEntryRepositoryImpl, + dependencies: [EntryToStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts new file mode 100644 index 00000000000..a3c03035d37 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts @@ -0,0 +1,136 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { UpdateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { UpdateEntryRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { EntryBeforeUpdateEvent, EntryAfterUpdateEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/abstractions.js"; +import type { + CmsEntry, + CmsModel, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "~/types/index.js"; +import type { GenericRecord } from "@webiny/api/types.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryLockedError } from "~/domains/contentEntries/errors.js"; +import { createUpdateEntryData } from "~/crud/contentEntry/entryDataFactories/createUpdateEntryData.js"; + +/** + * UpdateEntryUseCase - Orchestrates entry updates. + * + * Responsibilities: + * - Fetch original entry + * - Validate entry is not locked + * - Transform raw input to updated domain entry + * - Apply access control + * - Publish domain events + * - Delegate persistence to repository + */ +class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: UpdateEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private cmsContext: CmsContext.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private getRevisionByIdUseCase: GetRevisionByIdUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + rawInput: UpdateCmsEntryInput, + metaInput?: GenericRecord, + options?: UpdateCmsEntryOptionsInput + ): Promise> { + // Check initial access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + try { + const result = await this.getRevisionByIdUseCase.execute(model, id); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const originalEntry = result.value; + + // Check if entry is locked + if (originalEntry.locked) { + return Result.fail(new EntryLockedError(id)); + } + + // Transform raw input to updated domain entry + const { entry, input } = await createUpdateEntryData({ + model, + rawInput, + options, + context: this.cmsContext, + getIdentity: () => this.identityContext.getIdentity(), + getTenant: () => this.tenantContext.getTenant(), + originalEntry, + metaInput + }); + + // Apply access control on the updated entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(new NotAuthorizedError()); + } + + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeUpdateEvent({ entry, original: originalEntry, input, model }) + ); + + // Persist updated entry + const updateResult = await this.repository.execute(model, entry); + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterUpdateEvent({ + entry, + original: originalEntry, + input, + model + }) + ); + + return Result.ok(entry); + } catch (error) { + // Handle errors from createUpdateEntryData or other operations + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const UpdateEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: UpdateEntryUseCaseImpl, + dependencies: [ + EventPublisher, + UpdateEntryRepository, + AccessControl, + CmsContext, + TenantContext, + IdentityContext, + GetRevisionByIdUseCase + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts new file mode 100644 index 00000000000..3454de4f4e9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts @@ -0,0 +1,69 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { + CmsEntry, + CmsModel, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "~/types/index.js"; +import type { GenericRecord } from "@webiny/api/types.js"; +import type { + EntryNotFoundError, + EntryStorageError, + EntryValidationError, + EntryLockedError +} from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * UpdateEntry Use Case + */ +export interface IUpdateEntryUseCase { + execute( + model: CmsModel, + id: string, + input: UpdateCmsEntryInput, + metaInput?: GenericRecord, + options?: UpdateCmsEntryOptionsInput + ): Promise>; +} + +export interface IUpdateEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + locked: EntryLockedError; + validation: EntryValidationError; + repository: RepositoryError; +} + +type UseCaseError = IUpdateEntryUseCaseErrors[keyof IUpdateEntryUseCaseErrors]; + +export const UpdateEntryUseCase = createAbstraction("UpdateEntryUseCase"); + +export namespace UpdateEntryUseCase { + export type Interface = IUpdateEntryUseCase; + export type Error = UseCaseError; +} + +/** + * UpdateEntryRepository - Persists entry updates to storage. + * Takes a domain CmsEntry object and updates it. + */ +export interface IUpdateEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IUpdateEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IUpdateEntryRepositoryErrors[keyof IUpdateEntryRepositoryErrors]; + +export const UpdateEntryRepository = createAbstraction( + "UpdateEntryRepository" +); + +export namespace UpdateEntryRepository { + export type Interface = IUpdateEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts new file mode 100644 index 00000000000..7fdda7c169b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts @@ -0,0 +1,61 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsEntry, CmsModel, UpdateCmsEntryInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface EntryBeforeUpdatePayload { + entry: CmsEntry; + original: CmsEntry; + input: UpdateCmsEntryInput; + model: CmsModel; +} + +export interface EntryAfterUpdatePayload { + entry: CmsEntry; + original: CmsEntry; + input: UpdateCmsEntryInput; + model: CmsModel; +} + +/** + * EntryBeforeUpdateEvent - Published before updating an entry + */ +export class EntryBeforeUpdateEvent extends DomainEvent { + eventType = "entry.beforeUpdate" as const; + + getHandlerAbstraction() { + return EntryBeforeUpdateHandler; + } +} + +export const EntryBeforeUpdateHandler = createAbstraction>( + "EntryBeforeUpdateHandler" +); + +export namespace EntryBeforeUpdateHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeUpdateEvent; +} + +/** + * EntryAfterUpdateEvent - Published after updating an entry + */ +export class EntryAfterUpdateEvent extends DomainEvent { + eventType = "entry.afterUpdate" as const; + + getHandlerAbstraction() { + return EntryAfterUpdateHandler; + } +} + +export const EntryAfterUpdateHandler = createAbstraction>( + "EntryAfterUpdateHandler" +); + +export namespace EntryAfterUpdateHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterUpdateEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts new file mode 100644 index 00000000000..0f5c2d5fc08 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts @@ -0,0 +1,22 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "./UpdateEntryUseCase.js"; +import { UpdateEntryRepository } from "./UpdateEntryRepository.js"; + +/** + * UpdateEntry Feature + * + * Provides complete functionality for updating content entries: + * - Use case for orchestration + * - Repository for persistence + * - Events for extensibility + */ +export const UpdateEntryFeature = createFeature({ + name: "UpdateEntry", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(UpdateEntryUseCase); + + // Register repository in singleton scope (shared instance) + container.register(UpdateEntryRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts new file mode 100644 index 00000000000..32a36605870 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts @@ -0,0 +1,2 @@ +export { UpdateEntryUseCase } from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts new file mode 100644 index 00000000000..710deea57a0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts @@ -0,0 +1,489 @@ +// @ts-nocheck +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { EntriesRepository as RepositoryAbstraction } from "./abstractions.js"; +import { + EntryNotFoundError, + EntryStorageError, + EntryAlreadyPublishedError, + EntryNotPublishedError, + EntryInBinError, + EntryNotInBinError +} from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryMeta, CmsEntryListParams, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; +import { PluginsContainer } from "~/legacy/abstractions.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * EntriesRepository implementation following CQS principle. + * Provides access to database-stored entries with access control. + * Note: Entries are only stored in database, no plugin entries exist. + */ +class EntriesRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private pluginsContainer: PluginsContainer.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface + ) {} + + async getById( + model: CmsModel, + id: string + ): Promise> { + try { + const [storageEntry] = await this.storageOperations.entries.getByIds(model, { + ids: [id] + }); + + if (!storageEntry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + if (!canAccess) { + return Result.fail(new EntryNotFoundError(id)); + } + + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async getLatestRevision( + model: CmsModel, + entryId: string + ): Promise> { + try { + const storageEntry = await this.storageOperations.entries.getLatestRevisionByEntryId( + model, + { + id: entryId + } + ); + + if (!storageEntry) { + return Result.fail(new EntryNotFoundError(entryId)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async getPublishedRevision( + model: CmsModel, + entryId: string + ): Promise> { + try { + const storageEntry = await this.storageOperations.entries.getPublishedRevisionByEntryId( + model, + { + id: entryId + } + ); + + if (!storageEntry) { + return Result.fail(new EntryNotFoundError(entryId)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async getPreviousRevision( + model: CmsModel, + entryId: string, + version: number + ): Promise> { + try { + const storageEntry = await this.storageOperations.entries.getPreviousRevision(model, { + entryId, + version + }); + + if (!storageEntry) { + return Result.fail(new EntryNotFoundError(entryId)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async list( + model: CmsModel, + params: CmsEntryListParams + ): Promise> { + try { + const { where: initialWhere, limit: initialLimit } = params || {}; + const limit = initialLimit && initialLimit > 0 ? initialLimit : 50; + const where = { ...initialWhere }; + const listParams = { ...params, where, limit }; + + const { hasMoreItems, totalCount, cursor, items } = + await this.storageOperations.entries.list(model, listParams); + + // Apply access control to all entries + const accessibleEntries: CmsEntry[] = []; + for (const storageEntry of items) { + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + if (canAccess) { + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + accessibleEntries.push(entry); + } + } + + return Result.ok([accessibleEntries, { hasMoreItems, totalCount, cursor }]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async getRevisions( + model: CmsModel, + entryId: string + ): Promise> { + try { + const revisions = await this.storageOperations.entries.getRevisions(model, { + id: entryId + }); + + // Apply access control to all revisions + const accessibleRevisions: CmsEntry[] = []; + for (const storageEntry of revisions) { + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + + if (canAccess) { + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + accessibleRevisions.push(entry); + } + } + + return Result.ok(accessibleRevisions); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async getByIds( + model: CmsModel, + ids: string[] + ): Promise> { + try { + const entries = await this.storageOperations.entries.getByIds(model, { ids }); + + // Apply access control to all entries + const accessibleEntries: CmsEntry[] = []; + for (const storageEntry of entries) { + const canAccess = await this.accessControl.canAccessEntry({ + model, + entry: storageEntry + }); + if (canAccess) { + const entry = await entryFromStorageTransform( + { plugins: this.pluginsContainer }, + model, + storageEntry + ); + + accessibleEntries.push(entry); + } + } + + return Result.ok(accessibleEntries); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async create( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + const storageEntry = await entryToStorageTransform( + { plugins: this.pluginsContainer }, + model, + entry + ); + + await this.storageOperations.entries.create(model, { entry, storageEntry }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async createRevisionFrom( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + const storageEntry = await entryToStorageTransform( + { plugins: this.pluginsContainer }, + model, + entry + ); + await this.storageOperations.entries.createRevisionFrom(model, { entry, storageEntry }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async update( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Verify entry exists + const existingEntry = await this.storageOperations.entries.getRevisionById(model, { + id: entry.id + }); + + if (!existingEntry) { + return Result.fail(new EntryNotFoundError(entry.id)); + } + + const storageEntry = await entryToStorageTransform( + { plugins: this.pluginsContainer }, + model, + entry + ); + + await this.storageOperations.entries.update(model, { entry, storageEntry }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async delete(model: CmsModel, id: string): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + await this.storageOperations.entries.delete(model, id); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async deleteRevision( + model: CmsModel, + id: string + ): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + await this.storageOperations.entries.deleteRevision(model, id); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async publish(model: CmsModel, id: string): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.getRevisionById(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Check if already published + if (entry.status === "published") { + return Result.fail(new EntryAlreadyPublishedError(id)); + } + + await this.storageOperations.entries.publish(model, { entry }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async unpublish( + model: CmsModel, + id: string + ): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Check if not published + if (entry.status !== "published") { + return Result.fail(new EntryNotPublishedError(id)); + } + + await this.storageOperations.entries.unpublish(model, id); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async move( + model: CmsModel, + id: string, + folderId: string + ): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + await this.storageOperations.entries.move(model, id, folderId); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async moveToBin( + model: CmsModel, + id: string + ): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Check if already in bin + if (entry.wbyAco_location?.folderId === "bin") { + return Result.fail(new EntryInBinError(id)); + } + + await this.storageOperations.entries.moveToBin(model, id); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + async restoreFromBin( + model: CmsModel, + id: string + ): Promise> { + try { + // Verify entry exists + const entry = await this.storageOperations.entries.get(model, { id }); + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Check if not in bin + if (entry.wbyAco_location?.folderId !== "bin") { + return Result.fail(new EntryNotInBinError(id)); + } + + await this.storageOperations.entries.restoreFromBin(model, id); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const EntriesRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: EntriesRepositoryImpl, + dependencies: [PluginsContainer, StorageOperations, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts new file mode 100644 index 00000000000..f553dcc3832 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts @@ -0,0 +1,134 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryMeta, CmsEntryListParams, CmsModel } from "~/types/index.js"; +import type { + EntryNotFoundError, + EntryStorageError, + EntryValidationError +} from "~/domains/contentEntries/errors.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +export interface IEntriesRepositoryErrors { + base: EntryNotFoundError | EntryStorageError | EntryValidationError; + notAuthorized: NotAuthorizedError; +} + +type RepositoryError = IEntriesRepositoryErrors[keyof IEntriesRepositoryErrors]; + +/** + * EntriesRepository follows CQS (Command-Query Separation): + * - Queries (get, list, getRevisions, etc.): Return data wrapped in Result + * - Commands (create, update, delete, publish, etc.): Return Result + */ +export interface IEntriesRepository { + /** + * Get a specific entry revision by ID. + */ + getById(model: CmsModel, id: string): Promise>; + + /** + * Get the latest revision of an entry by entry ID. + */ + getLatestRevision( + model: CmsModel, + entryId: string + ): Promise>; + + /** + * Get the published revision of an entry by entry ID. + */ + getPublishedRevision( + model: CmsModel, + entryId: string + ): Promise>; + + /** + * Get the previous revision of an entry. + */ + getPreviousRevision( + model: CmsModel, + entryId: string, + version: number + ): Promise>; + + /** + * List entries with filtering and pagination. + */ + list( + model: CmsModel, + params: CmsEntryListParams + ): Promise>; + + /** + * Get all revisions of an entry. + */ + getRevisions(model: CmsModel, entryId: string): Promise>; + + /** + * Get multiple entries by their IDs. + */ + getByIds(model: CmsModel, ids: string[]): Promise>; + + /** + * Create a new entry. + */ + create(model: CmsModel, entry: CmsEntry): Promise>; + + /** + * Create a new revision from an existing entry. + */ + createRevisionFrom( + model: CmsModel, + entry: CmsEntry + ): Promise>; + + /** + * Update an existing entry. + */ + update( + model: CmsModel, + entry: CmsEntry, + ): Promise>; + + /** + * Delete an entry (hard delete with all revisions). + */ + delete(model: CmsModel, id: string): Promise>; + + /** + * Delete a specific revision. + */ + deleteRevision(model: CmsModel, id: string): Promise>; + + /** + * Publish an entry. + */ + publish(model: CmsModel, id: string): Promise>; + + /** + * Unpublish an entry. + */ + unpublish(model: CmsModel, id: string): Promise>; + + /** + * Move an entry to a folder/location. + */ + move(model: CmsModel, id: string, folderId: string): Promise>; + + /** + * Move an entry to bin (soft delete). + */ + moveToBin(model: CmsModel, id: string): Promise>; + + /** + * Restore an entry from bin. + */ + restoreFromBin(model: CmsModel, id: string): Promise>; +} + +export const EntriesRepository = createAbstraction("EntriesRepository"); + +export namespace EntriesRepository { + export type Interface = IEntriesRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts new file mode 100644 index 00000000000..e9ac290cbea --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts @@ -0,0 +1,143 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GroupsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { PluginGroupsProvider } from "./abstractions.js"; +import { + GroupNotFoundError, + GroupStorageError, + GroupCannotUpdateCodeDefinedError, + GroupCannotDeleteCodeDefinedError +} from "~/domains/contentModelGroups/errors.js"; +import type { CmsGroup } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; + +/** + * GroupsRepository implementation following CQS principle. + * Provides unified access to both database-stored and plugin-defined groups. + */ +class GroupsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private tenantContext: TenantContext.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private accessControl: AccessControl.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async get(groupId: string): Promise> { + try { + // 1. Check plugin groups first (code-defined, immutable) + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const pluginGroup = pluginGroups.find(g => g.id === groupId); + + if (pluginGroup) { + // Apply access control + const canAccess = await this.accessControl.canAccessGroup({ group: pluginGroup }); + if (!canAccess) { + return Result.fail(new GroupNotFoundError(groupId)); + } + return Result.ok(pluginGroup); + } + + // 2. Query database groups + const tenant = this.tenantContext.getTenant(); + const dbGroup = await this.storageOperations.groups.get({ + id: groupId, + tenant: tenant.id + }); + if (!dbGroup) { + return Result.fail(new GroupNotFoundError(groupId)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessGroup({ group: dbGroup }); + if (!canAccess) { + return Result.fail(new GroupNotFoundError(groupId)); + } + + return Result.ok(dbGroup); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + async list(): Promise> { + try { + // 1. Get plugin groups + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + + // 2. Get DB groups + const tenant = this.tenantContext.getTenant(); + const dbGroups = await this.storageOperations.groups.list({ + where: { tenant: tenant.id } + }); + + // 3. Combine both sources + const allGroups = [...pluginGroups, ...dbGroups]; + + // 4. Apply access control to all groups + const accessibleGroups: CmsGroup[] = []; + for (const group of allGroups) { + const canAccess = await this.accessControl.canAccessGroup({ group }); + if (canAccess) { + accessibleGroups.push(group); + } + } + + return Result.ok(accessibleGroups); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + async create(group: CmsGroup): Promise> { + try { + // Only DB groups can be created (plugin groups are code-defined) + await this.storageOperations.groups.create({ group }); + return Result.ok(); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + async update(group: CmsGroup): Promise> { + try { + // Cannot update plugin groups + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const isPluginGroup = pluginGroups.some(g => g.id === group.id); + + if (isPluginGroup) { + return Result.fail(new GroupCannotUpdateCodeDefinedError(group.id)); + } + + await this.storageOperations.groups.update({ group }); + return Result.ok(); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + async delete(group: CmsGroup): Promise> { + try { + // Cannot delete plugin groups + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const isPluginGroup = pluginGroups.some(g => g.id === group.id); + + if (isPluginGroup) { + return Result.fail(new GroupCannotDeleteCodeDefinedError(group.id)); + } + + await this.storageOperations.groups.delete({ group }); + return Result.ok(); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } +} + +export const GroupsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GroupsRepositoryImpl, + dependencies: [TenantContext, PluginGroupsProvider, AccessControl, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts b/packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts new file mode 100644 index 00000000000..3e886342c88 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts @@ -0,0 +1,35 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { PluginGroupsProvider as ProviderAbstraction } from "./abstractions.js"; +import type { CmsGroup } from "~/types/index.js"; + +export type GetGroups = () => CmsGroup[]; + +/** + * PluginGroupsProvider implementation that fetches groups from plugins. + */ +export class PluginGroupsProvider implements ProviderAbstraction.Interface { + constructor( + private tenantContext: TenantContext.Interface, + private getCmsGroups: GetGroups + ) {} + + async getGroups(): Promise { + const tenant = this.tenantContext.getTenant(); + + const groups = this.getCmsGroups(); + + return groups + .filter(group => { + // Filter by tenant if specified in plugin + // If not specified, plugin group is available for all tenants/locales + if (group.tenant && group.tenant !== tenant.id) { + return false; + } + return true; + }) + .map(group => ({ + ...group, + tenant: tenant.id + })) as CmsGroup[]; + } +} diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts new file mode 100644 index 00000000000..2f2357669c0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts @@ -0,0 +1,83 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { + GroupNotFoundError, + GroupStorageError, + GroupCannotUpdateCodeDefinedError, + GroupCannotDeleteCodeDefinedError +} from "~/domains/contentModelGroups/errors.js"; + +export interface IGroupsRepositoryErrors { + base: + | GroupNotFoundError + | GroupStorageError + | GroupCannotUpdateCodeDefinedError + | GroupCannotDeleteCodeDefinedError; +} + +type RepositoryError = IGroupsRepositoryErrors[keyof IGroupsRepositoryErrors]; + +/** + * GroupsRepository follows CQS (Command-Query Separation): + * - Queries (get, list): Return data wrapped in Result + * - Commands (create, update, delete): Return Result + * + * This repository provides unified access to both database-stored groups + * and plugin-defined (code) groups, transparently handling access control. + */ +export interface IGroupsRepository { + /** + * Get a single group by ID. + * Checks plugin groups first, then database groups. + * Applies access control. + */ + get(groupId: string): Promise>; + + /** + * List all accessible groups. + * Combines plugin groups and database groups. + * Applies access control to all results. + */ + list(): Promise>; + + /** + * Create a new group in the database. + * Plugin groups cannot be created (they are code-defined). + */ + create(group: CmsGroup): Promise>; + + /** + * Update an existing database group. + * Plugin groups cannot be updated. + */ + update(group: CmsGroup): Promise>; + + /** + * Delete a database group. + * Plugin groups cannot be deleted. + */ + delete(group: CmsGroup): Promise>; +} + +export const GroupsRepository = createAbstraction("GroupsRepository"); + +export namespace GroupsRepository { + export type Interface = IGroupsRepository; + export type Error = RepositoryError; +} + +/** + * PluginGroupsProvider provides access to plugin-defined (code) groups. + */ +export interface IPluginGroupsProvider { + getGroups(): Promise; +} + +export const PluginGroupsProvider = createAbstraction( + "PluginGroupsProvider" +); + +export namespace PluginGroupsProvider { + export type Interface = IPluginGroupsProvider; +} diff --git a/packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts b/packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts new file mode 100644 index 00000000000..17ff525347d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts @@ -0,0 +1,141 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { ModelsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { PluginModelsProvider } from "./abstractions.js"; +import { + ModelNotFoundError, + ModelStorageError, + ModelCannotUpdateCodeDefinedError, + ModelCannotDeleteCodeDefinedError +} from "~/domains/contentModels/errors.js"; +import type { CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; + +/** + * ModelsRepository implementation following CQS principle. + * Provides unified access to both database-stored and plugin-defined models. + */ +class ModelsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private tenantContext: TenantContext.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private accessControl: AccessControl.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async get(modelId: string): Promise> { + try { + // 1. Check plugin models first (code-defined, immutable) TODO: move to decorator! + const pluginModels = await this.pluginModelsProvider.getModels(); + const pluginModel = pluginModels.find(m => m.modelId === modelId); + + if (pluginModel) { + // Apply access control + const canAccess = await this.accessControl.canAccessModel({ model: pluginModel }); + if (!canAccess) { + return Result.fail(new ModelNotFoundError(modelId)); + } + return Result.ok(pluginModel); + } + + // 2. Query database models + const tenant = this.tenantContext.getTenant(); + const dbModel = await this.storageOperations.models.get({ tenant: tenant.id, modelId }); + if (!dbModel) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + // Apply access control + const canAccess = await this.accessControl.canAccessModel({ model: dbModel }); + if (!canAccess) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + return Result.ok(dbModel); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async list(): Promise> { + try { + // 1. Get plugin models TODO: move to decorator! + const pluginModels = await this.pluginModelsProvider.getModels(); + + // 2. Get DB models + const tenant = this.tenantContext.getTenant(); + const dbModels = await this.storageOperations.models.list({ + where: { tenant: tenant.id } + }); + + // 3. Combine both sources TODO: move to decorator! + const allModels = [...pluginModels, ...dbModels]; + + // 4. Apply access control to all models + const accessibleModels: CmsModel[] = []; + for (const model of allModels) { + const canAccess = await this.accessControl.canAccessModel({ model }); + if (canAccess) { + accessibleModels.push(model); + } + } + + return Result.ok(accessibleModels); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async create(model: CmsModel): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + model.tenant = tenant.id; + await this.storageOperations.models.create({ model }); + return Result.ok(); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async update(model: CmsModel): Promise> { + try { + // Cannot update plugin models. TODO: move to decorator! + const pluginModels = await this.pluginModelsProvider.getModels(); + const isPluginModel = pluginModels.some(m => m.modelId === model.modelId); + + if (isPluginModel) { + return Result.fail(new ModelCannotUpdateCodeDefinedError(model.modelId)); + } + + await this.storageOperations.models.update({ model }); + return Result.ok(); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } + + async delete(model: CmsModel): Promise> { + try { + // Cannot delete plugin models TODO: move to decorator! + const pluginModels = await this.pluginModelsProvider.getModels(); + const isPluginModel = pluginModels.some(m => m.modelId === model.modelId); + + if (isPluginModel) { + return Result.fail(new ModelCannotDeleteCodeDefinedError(model.modelId)); + } + + await this.storageOperations.models.delete({ model }); + return Result.ok(); + } catch (error) { + return Result.fail(new ModelStorageError(error as Error)); + } + } +} + +export const ModelsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: ModelsRepositoryImpl, + dependencies: [TenantContext, PluginModelsProvider, AccessControl, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts b/packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts new file mode 100644 index 00000000000..1b8d541c442 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts @@ -0,0 +1,35 @@ +import { PluginModelsProvider as ProviderAbstraction } from "./abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import type { CmsModel } from "~/types/index.js"; + +export type GetCmsModels = () => CmsModel[]; + +/** + * PluginModelsProvider implementation that fetches models from plugins. + */ +export class PluginModelsProvider implements ProviderAbstraction.Interface { + constructor( + private tenantContext: TenantContext.Interface, + private getCmsModels: GetCmsModels + ) {} + + async getModels(): Promise { + const tenant = this.tenantContext.getTenant(); + + const models = this.getCmsModels(); + + return models + .filter(model => { + // Filter by tenant/locale if specified in plugin + // If not specified, plugin model is available for all tenants/locales + if (model.tenant && model.tenant !== tenant.id) { + return false; + } + return true; + }) + .map(model => ({ + ...model, + tenant: tenant.id + })) as CmsModel[]; + } +} diff --git a/packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts new file mode 100644 index 00000000000..7e6dda1e879 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts @@ -0,0 +1,82 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import type { + ModelNotFoundError, + ModelStorageError, + ModelCannotUpdateCodeDefinedError, + ModelCannotDeleteCodeDefinedError +} from "~/domains/contentModels/errors.js"; + +export interface IModelsRepositoryErrors { + base: + | ModelNotFoundError + | ModelStorageError + | ModelCannotUpdateCodeDefinedError + | ModelCannotDeleteCodeDefinedError; +} + +type RepositoryError = IModelsRepositoryErrors[keyof IModelsRepositoryErrors]; + +/** + * ModelsRepository follows CQS (Command-Query Separation): + * - Queries (get, list): Return data wrapped in Result + * - Commands (create, update, delete): Return Result + * + * This repository provides unified access to both database-stored models + * and plugin-defined (code) models, transparently handling access control. + */ +export interface IModelsRepository { + /** + * Get a single model by ID. + * Checks plugin models first, then database models. + * Applies access control. + */ + get(modelId: string): Promise>; + + /** + * List all accessible models. + * Combines plugin models and database models. + * Applies access control to all results. + */ + list(): Promise>; + + /** + * Create a new model in the database. + * Plugin models cannot be created (they are code-defined). + */ + create(model: CmsModel): Promise>; + + /** + * Update an existing database model. + * Plugin models cannot be updated. + */ + update(model: CmsModel): Promise>; + + /** + * Delete a database model. + * Plugin models cannot be deleted. + */ + delete(model: CmsModel): Promise>; +} + +export const ModelsRepository = createAbstraction("ModelsRepository"); + +export namespace ModelsRepository { + export type Interface = IModelsRepository; + export type Error = RepositoryError; +} + +/** + * PluginModelsProvider provides access to plugin-defined (code) models. + */ +export interface IPluginModelsProvider { + getModels(): Promise; +} + +export const PluginModelsProvider = + createAbstraction("PluginModelsProvider"); + +export namespace PluginModelsProvider { + export type Interface = IPluginModelsProvider; +} diff --git a/packages/api-headless-cms/src/features/shared/abstractions.ts b/packages/api-headless-cms/src/features/shared/abstractions.ts new file mode 100644 index 00000000000..8a3a45844b6 --- /dev/null +++ b/packages/api-headless-cms/src/features/shared/abstractions.ts @@ -0,0 +1,34 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { HeadlessCmsStorageOperations as StorageOps } from "~/types/types.js"; +import type { AccessControl as AccessControlClass } from "~/crud/AccessControl/AccessControl.js"; +import type { CmsContext as CmsCtx } from "~/types/types.js"; + +/** + * StorageOperations abstraction for legacy storage operations. + * The legacy implementation will be registered using container.registerInstance. + */ +export const StorageOperations = createAbstraction("StorageOperations"); + +export namespace StorageOperations { + export type Interface = StorageOps; +} + +/** + * AccessControl abstraction for legacy access control. + * The legacy implementation will be registered using container.registerInstance. + */ +export const AccessControl = createAbstraction("AccessControl"); + +export namespace AccessControl { + export type Interface = AccessControlClass; +} + +/** + * CmsContext abstraction for legacy CMS context. + * The legacy implementation will be registered using container.registerInstance. + */ +export const CmsContext = createAbstraction("CmsContext"); + +export namespace CmsContext { + export type Interface = CmsCtx; +} diff --git a/packages/api-headless-cms/src/graphql/getSchema.ts b/packages/api-headless-cms/src/graphql/getSchema.ts index 720495e47e0..0b8efe97d57 100644 --- a/packages/api-headless-cms/src/graphql/getSchema.ts +++ b/packages/api-headless-cms/src/graphql/getSchema.ts @@ -6,7 +6,6 @@ import type { GraphQLSchema } from "graphql"; import { generateCacheId } from "./getSchema/generateCacheId.js"; import { generateCacheKey } from "./getSchema/generateCacheKey.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; interface SchemaCache { key: string; @@ -17,7 +16,6 @@ interface GetSchemaParams { context: CmsContext; type: ApiEndpoint; getTenant: () => Tenant; - getLocale: () => I18NLocale; } const schemaList = new Map(); diff --git a/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts b/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts index e9890826fe2..f15a375421b 100644 --- a/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts +++ b/packages/api-headless-cms/src/graphql/getSchema/generateCacheId.ts @@ -1,14 +1,12 @@ import type { ApiEndpoint } from "~/types/index.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; interface GenerateCacheIdParams { type: ApiEndpoint; getTenant: () => Tenant; - getLocale: () => I18NLocale; } export const generateCacheId = (params: GenerateCacheIdParams): string => { - const { getTenant, type, getLocale } = params; - return [`tenant:${getTenant().id}`, `endpoint:${type}`, `locale:${getLocale().code}`].join("#"); + const { getTenant, type } = params; + return [`tenant:${getTenant().id}`, `endpoint:${type}`].join("#"); }; diff --git a/packages/api-headless-cms/src/graphql/handleRequest.ts b/packages/api-headless-cms/src/graphql/handleRequest.ts index ebe62f13371..b79c56800a6 100644 --- a/packages/api-headless-cms/src/graphql/handleRequest.ts +++ b/packages/api-headless-cms/src/graphql/handleRequest.ts @@ -37,16 +37,11 @@ export const handleRequest: HandleRequest = async params => { return context.tenancy.getCurrentTenant(); }; - const getLocale = () => { - return context.cms.getLocale(); - }; - const schema = await context.benchmark.measure("headlessCms.graphql.getSchema", async () => { try { return await getSchema({ context, getTenant, - getLocale, type: context.cms.type as ApiEndpoint }); } catch (ex) { diff --git a/packages/api-headless-cms/src/graphql/index.ts b/packages/api-headless-cms/src/graphql/index.ts index aef1f02523c..65a7af6ab0b 100644 --- a/packages/api-headless-cms/src/graphql/index.ts +++ b/packages/api-headless-cms/src/graphql/index.ts @@ -1,10 +1,9 @@ import type { Plugin } from "@webiny/plugins/types.js"; -import { createSystemSchemaPlugin } from "./system.js"; import type { GraphQLHandlerFactoryParams } from "./graphQLHandlerFactory.js"; import { graphQLHandlerFactory } from "./graphQLHandlerFactory.js"; import { createBaseSchema } from "~/graphql/schema/baseSchema.js"; export type CreateGraphQLParams = GraphQLHandlerFactoryParams; export const createGraphQL = (params: CreateGraphQLParams): Plugin[] => { - return [createBaseSchema(), createSystemSchemaPlugin(), ...graphQLHandlerFactory(params)]; + return [createBaseSchema(), ...graphQLHandlerFactory(params)]; }; diff --git a/packages/api-headless-cms/src/graphql/system.ts b/packages/api-headless-cms/src/graphql/system.ts deleted file mode 100644 index 1611fd67ca2..00000000000 --- a/packages/api-headless-cms/src/graphql/system.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { ErrorResponse, GraphQLSchemaPlugin, Response } from "@webiny/handler-graphql"; -import type { CmsContext } from "~/types/index.js"; - -const emptyResolver = () => ({}); - -const plugin = new GraphQLSchemaPlugin({ - typeDefs: /* GraphQL */ ` - extend type Query { - cms: CmsQuery - } - - extend type Mutation { - cms: CmsMutation - } - - type CmsQuery { - _empty: String - } - - type CmsMutation { - _empty: String - } - extend type CmsQuery { - # Get installed version - version: String - } - - extend type CmsMutation { - # Install CMS - install: CmsBooleanResponse - } - `, - resolvers: { - Query: { - cms: emptyResolver - }, - Mutation: { - cms: emptyResolver - }, - CmsQuery: { - version: async (_: any, __: any, context: CmsContext) => { - try { - const version = await context.cms.getSystemVersion(); - return version ? "true" : null; - } catch (e) { - return new ErrorResponse(e); - } - } - }, - CmsMutation: { - install: async (_: any, __: any, { cms }: CmsContext) => { - try { - const version = await cms.getSystemVersion(); - if (version) { - return new ErrorResponse({ - code: "CMS_INSTALLATION_ERROR", - message: "CMS is already installed." - }); - } - - await cms.installSystem(); - return new Response(true); - } catch (e) { - return new ErrorResponse(e); - } - } - } - } -}); -plugin.name = "cms.graphql.schema.system"; - -export const createSystemSchemaPlugin = () => { - return plugin; -}; diff --git a/packages/api-headless-cms/src/legacy/abstractions.ts b/packages/api-headless-cms/src/legacy/abstractions.ts new file mode 100644 index 00000000000..ca40052e4ce --- /dev/null +++ b/packages/api-headless-cms/src/legacy/abstractions.ts @@ -0,0 +1,41 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { PluginsContainer as PluginsContainerType } from "@webiny/plugins"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; + +export const PluginsContainer = createAbstraction("PluginsContainer"); + +export namespace PluginsContainer { + export type Interface = PluginsContainerType; +} + +/** + * EntryToStorageTransform - Transforms domain entry to storage format. + * Legacy abstraction for the utility function. + */ +export interface IEntryToStorageTransform { + (model: CmsModel, entry: CmsEntry): Promise; +} + +export const EntryToStorageTransform = createAbstraction( + "EntryToStorageTransform" +); + +export namespace EntryToStorageTransform { + export type Interface = IEntryToStorageTransform; +} + +/** + * EntryFromStorageTransform - Transforms storage entry to domain format. + * Legacy abstraction for the utility function. + */ +export interface IEntryFromStorageTransform { + (model: CmsModel, entry: CmsEntry): Promise; +} + +export const EntryFromStorageTransform = createAbstraction( + "EntryFromStorageTransform" +); + +export namespace EntryFromStorageTransform { + export type Interface = IEntryFromStorageTransform; +} diff --git a/packages/api-headless-cms/src/types/modelGroup.ts b/packages/api-headless-cms/src/types/modelGroup.ts index 5795dcc994e..0e34978a25c 100644 --- a/packages/api-headless-cms/src/types/modelGroup.ts +++ b/packages/api-headless-cms/src/types/modelGroup.ts @@ -38,10 +38,6 @@ export interface CmsGroup { * Group tenant. */ tenant: string; - /** - * Locale this group belongs to. - */ - locale: string; /** * Description for the group. */ diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 193fb927ff1..fa52510a280 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -20,7 +20,6 @@ import type { CmsGroup } from "./modelGroup.js"; import type { CmsIdentity } from "./identity.js"; import type { ISingletonModelManager } from "~/modelManager/index.js"; import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; export interface CmsError { @@ -39,23 +38,11 @@ export interface CmsError { export type ApiEndpoint = "manage" | "preview" | "read"; -export interface HeadlessCms - extends CmsSystemContext, - CmsGroupContext, - CmsModelContext, - CmsEntryContext { +export interface HeadlessCms extends CmsGroupContext, CmsModelContext, CmsEntryContext { /** * API type */ type: ApiEndpoint | null; - /** - * Requested locale - */ - locale: string; - /** - * returns an instance of current locale - */ - getLocale: () => I18NLocale; /** * Means this request is a READ API */ @@ -234,34 +221,6 @@ export interface CmsFieldTypePlugins { [key: string]: CmsModelFieldToGraphQLPlugin; } -export interface OnSystemBeforeInstallTopicParams { - tenant: string; - locale: string; -} - -export interface OnSystemAfterInstallTopicParams { - tenant: string; - locale: string; -} - -export interface OnSystemInstallErrorTopicParams { - error: Error; - tenant: string; - locale: string; -} - -export type CmsSystemContext = { - getSystemVersion: () => Promise; - setSystemVersion: (version: string) => Promise; - installSystem: () => Promise; - /** - * Lifecycle Events - */ - onSystemBeforeInstall: Topic; - onSystemAfterInstall: Topic; - onSystemInstallError: Topic; -}; - /** * A GraphQL `params.data` parameter received when creating content model group. * @@ -298,7 +257,6 @@ export interface CmsGroupUpdateInput { export interface CmsGroupListParams { where: { tenant: string; - locale: string; }; } @@ -642,7 +600,6 @@ export interface CmsEntry { * A locale of the entry. * @see I18NLocale.code */ - locale: string; /** * A revision version of the entry. */ @@ -1704,12 +1661,10 @@ export interface CmsEntryPermission extends BaseCmsSecurityPermission { export interface CmsGroupStorageOperationsGetParams { id: string; tenant: string; - locale: string; } export interface CmsGroupStorageOperationsListWhereParams { tenant: string; - locale: string; [key: string]: any; } @@ -1761,13 +1716,11 @@ export interface CmsGroupStorageOperations { export interface CmsModelStorageOperationsGetParams { tenant: string; - locale: string; modelId: string; } export interface CmsModelStorageOperationsListWhereParams { tenant: string; - locale: string; [key: string]: string; } @@ -2169,36 +2122,8 @@ export interface CmsSystem { tenant: string; } -export interface CmsSystemStorageOperationsGetParams { - tenant: string; -} - -export interface CmsSystemStorageOperationsCreateParams { - system: CmsSystem; -} - -export interface CmsSystemStorageOperationsUpdateParams { - system: CmsSystem; -} - -export interface CmsSystemStorageOperations { - /** - * Get the system data. - */ - get: (params: CmsSystemStorageOperationsGetParams) => Promise; - /** - * Create the system info in the storage. - */ - create: (params: CmsSystemStorageOperationsCreateParams) => Promise; - /** - * Update the system info in the storage. - */ - update: (params: CmsSystemStorageOperationsUpdateParams) => Promise; -} - export interface HeadlessCmsStorageOperations { name: string; - system: CmsSystemStorageOperations; groups: CmsGroupStorageOperations; models: CmsModelStorageOperations; entries: CmsEntryStorageOperations; From dde2e233ca0b03afae945f53baffd66a6d2091ac Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 10 Nov 2025 21:28:26 +0100 Subject: [PATCH 04/71] wip: migrate crud files to features --- ai-context/backend-developer-guide.md | 6 +- ai-context/core-features-reference.md | 14 +- packages/api-headless-cms/src/context.ts | 10 +- .../src/crud/contentEntry.crud.ts | 520 ++++++++---------- .../contentEntries/ContentEntriesFeature.ts | 20 + .../DeleteEntry/DeleteEntryRepository.ts | 31 ++ .../DeleteEntry/DeleteEntryUseCase.ts | 127 +++++ .../DeleteEntry/MoveEntryToBinRepository.ts | 43 ++ .../DeleteEntry/MoveEntryToBinUseCase.ts | 149 +++++ .../DeleteEntry/abstractions.ts | 113 ++++ .../decorators/ForceDeleteDecorator.ts | 55 ++ .../contentEntries/DeleteEntry/events.ts | 68 +++ .../contentEntries/DeleteEntry/feature.ts | 22 + .../contentEntries/DeleteEntry/index.ts | 11 + .../DeleteEntryRevisionRepository.ts | 63 +++ .../DeleteEntryRevisionUseCase.ts | 168 ++++++ .../DeleteEntryRevision/abstractions.ts | 81 +++ .../DeleteEntryRevision/events.ts | 68 +++ .../DeleteEntryRevision/feature.ts | 14 + .../DeleteEntryRevision/index.ts | 6 + .../GetEntriesByIdsRepository.ts | 44 ++ .../GetEntriesByIds/GetEntriesByIdsUseCase.ts | 47 ++ .../GetEntriesByIds/abstractions.ts | 57 ++ .../GetEntriesByIdsNotDeletedDecorator.ts | 38 ++ .../contentEntries/GetEntriesByIds/feature.ts | 13 + .../contentEntries/GetEntriesByIds/index.ts | 1 + .../GetEntry/GetEntryUseCase.ts | 45 ++ .../contentEntries/GetEntry/abstractions.ts | 31 ++ .../contentEntries/GetEntry/feature.ts | 9 + .../features/contentEntries/GetEntry/index.ts | 1 + .../GetEntryById/GetEntryByIdUseCase.ts | 40 ++ .../GetEntryById/abstractions.ts | 31 ++ .../contentEntries/GetEntryById/feature.ts | 9 + .../contentEntries/GetEntryById/index.ts | 1 + .../GetLatestEntriesByIdsRepository.ts | 44 ++ .../GetLatestEntriesByIdsUseCase.ts | 47 ++ .../GetLatestEntriesByIds/abstractions.ts | 57 ++ ...etLatestEntriesByIdsNotDeletedDecorator.ts | 38 ++ .../GetLatestEntriesByIds/feature.ts | 13 + .../GetLatestEntriesByIds/index.ts | 1 + .../GetLatestRevisionByEntryId/BaseUseCase.ts | 53 ++ .../GetLatestRevisionByEntryIdRepository.ts | 44 ++ .../abstractions.ts | 109 ++++ .../GetLatestRevisionByEntryId/feature.ts | 27 + .../GetLatestRevisionByEntryId/index.ts | 6 + ...etLatestDeletedRevisionByEntryIdUseCase.ts | 47 ++ ...evisionByEntryIdIncludingDeletedUseCase.ts | 26 + .../GetLatestRevisionByEntryIdUseCase.ts | 45 ++ .../BaseUseCase.ts | 53 ++ .../GetPreviousRevisionByEntryIdRepository.ts | 51 ++ .../GetPreviousRevisionByEntryIdUseCase.ts | 46 ++ .../abstractions.ts | 80 +++ .../GetPreviousRevisionByEntryId/feature.ts | 18 + .../GetPreviousRevisionByEntryId/index.ts | 4 + .../GetPublishedEntriesByIdsRepository.ts | 44 ++ .../GetPublishedEntriesByIdsUseCase.ts | 47 ++ .../GetPublishedEntriesByIds/abstractions.ts | 57 ++ ...ublishedEntriesByIdsNotDeletedDecorator.ts | 38 ++ .../GetPublishedEntriesByIds/feature.ts | 13 + .../GetPublishedEntriesByIds/index.ts | 1 + .../contentEntries/GetRevisionById/feature.ts | 2 +- .../GetRevisionsByEntryIdRepository.ts | 46 ++ .../GetRevisionsByEntryIdUseCase.ts | 47 ++ .../GetRevisionsByEntryId/abstractions.ts | 57 ++ .../GetRevisionsByEntryId/feature.ts | 11 + .../GetRevisionsByEntryId/index.ts | 1 + .../ListEntries/ListDeletedEntriesUseCase.ts | 42 ++ .../ListEntries/ListEntriesRepository.ts | 66 +++ .../ListEntries/ListEntriesUseCase.ts | 67 +++ .../ListEntries/ListLatestEntriesUseCase.ts | 41 ++ .../ListPublishedEntriesUseCase.ts | 41 ++ .../ListEntries/abstractions.ts | 118 ++++ .../contentEntries/ListEntries/feature.ts | 20 + .../contentEntries/ListEntries/index.ts | 5 + .../src/legacy/abstractions.ts | 23 +- .../api-headless-cms/src/types/context.ts | 4 +- 76 files changed, 3257 insertions(+), 299 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts diff --git a/ai-context/backend-developer-guide.md b/ai-context/backend-developer-guide.md index d27546fe5a8..cd9ef54f724 100644 --- a/ai-context/backend-developer-guide.md +++ b/ai-context/backend-developer-guide.md @@ -486,8 +486,8 @@ Events must follow this structure with handler abstractions: ```typescript // features/teams/CreateTeam/events.ts import { createAbstraction } from "@webiny/feature/api"; -import { DomainEvent } from "@webiny/api-core"; -import type { IEventHandler } from "@webiny/api-core"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; import type { TeamBeforeCreatePayload, TeamAfterCreatePayload } from "./abstractions.js"; // Before event with handler abstraction @@ -550,7 +550,7 @@ Publish events from use cases using EventPublisher: ```typescript // features/teams/CreateTeam/CreateTeamUseCase.ts -import { EventPublisher } from "@webiny/api-core"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { TeamBeforeCreateEvent, TeamAfterCreateEvent } from "./events.js"; class CreateTeamUseCaseImpl implements CreateTeam.Interface { diff --git a/ai-context/core-features-reference.md b/ai-context/core-features-reference.md index 43d22007c63..6293ea564ee 100644 --- a/ai-context/core-features-reference.md +++ b/ai-context/core-features-reference.md @@ -12,23 +12,23 @@ This document provides the correct import paths and type definitions for commonl ## Features ### TenantContext -- **Import:** `import { TenantContext } from "@webiny/api-tenancy/features/TenantContext"` -- **Interface Type:** See `packages/api-tenancy/src/features/TenantContext/abstractions.ts` +- **Import:** `import { TenantContext } from "@webiny/api-core/features/TenantContext"` +- **Interface Type:** See `packages/api-core/src/features/TenantContext/abstractions.ts` - **Usage:** Access current tenant information ### IdentityContext -- **Import:** `import { IdentityContext } from "@webiny/api-security/features/IdentityContext"` -- **Interface Type:** See `packages/api-security/src/features/IdentityContext/abstractions.ts` +- **Import:** `import { IdentityContext } from "@webiny/api-core/features/IdentityContext"` +- **Interface Type:** See `packages/api-core/src/features/IdentityContext/abstractions.ts` - **Usage:** Access current user identity and permissions ### EventPublisher -- **Import:** `import { EventPublisher } from "@webiny/api-core"` +- **Import:** `import { EventPublisher } from "@webiny/api-core/features/EventPublisher"` - **Interface Type:** See `packages/api-core/src/event-publisher/abstractions.ts` - **Usage:** Publish domain events ### WcpContext -- **Import:** `import { WcpContext } from "@webiny/api-wcp/features/WcpContext"` -- **Interface Type:** See `packages/api-wcp/src/features/WcpContext/abstractions.ts` +- **Import:** `import { WcpContext } from "@webiny/api-core/features/WcpContext"` +- **Interface Type:** See `packages/api-core/src/features/WcpContext/abstractions.ts` - **Usage:** WCP (Webiny Control Panel) integration for seats/tenants management ### GetSettings diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index 8ac3be20408..eb5532f4e02 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -25,9 +25,11 @@ import { import { EntryFromStorageTransform, EntryToStorageTransform, - PluginsContainer + PluginsContainer, + SearchableFieldsProvider } from "./legacy/abstractions.js"; import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; +import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; const getParameters = async (context: CmsContext): Promise => { const plugins = context.plugins.byType(CmsParametersPlugin.type); @@ -108,8 +110,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { const accessControl = new AccessControl({ getIdentity: () => context.security.getIdentity(), - getGroupsPermissions: () => - context.security.getPermissions("cms.contentModelGroup"), + getGroupsPermissions: () => context.security.getPermissions("cms.contentModelGroup"), getModelsPermissions: () => context.security.getPermissions("cms.contentModel"), getEntriesPermissions: () => context.security.getPermissions("cms.contentEntry"), listAllGroups: () => { @@ -169,6 +170,9 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { context.container.registerInstance(EntryFromStorageTransform, (model, entry) => { return entryFromStorageTransform(context, model, entry); }); + context.container.registerInstance(SearchableFieldsProvider, params => { + return getSearchableFields({ plugins: context.plugins, ...params }); + }); // Register features CmsInstallerFeature.register(context.container, context.cms); diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 54c8955cce8..791736c29a0 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -7,7 +7,6 @@ import type { CmsEntryContext, CmsEntryGetParams, CmsEntryListParams, - CmsEntryListWhere, CmsEntryMeta, CmsEntryValues, CmsModel, @@ -65,7 +64,6 @@ import { } from "~/utils/entryStorage.js"; import { getSearchableFields } from "./contentEntry/searchableFields.js"; import { filterAsync } from "~/utils/filterAsync.js"; -import { isEntryLevelEntryMetaField, pickEntryMetaFields } from "~/constants.js"; import { createEntryRevisionFromData, createPublishEntryData, @@ -75,16 +73,7 @@ import { } from "./contentEntry/entryDataFactories/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import { - deleteEntryUseCases, - getEntriesByIdsUseCases, - getLatestEntriesByIdsUseCases, - getLatestRevisionByEntryIdUseCases, - getPreviousRevisionByEntryIdUseCases, - getPublishedEntriesByIdsUseCases, getPublishedRevisionByEntryIdUseCases, - getRevisionByIdUseCases, - getRevisionsByEntryIdUseCases, - listEntriesUseCases, restoreEntryFromBinUseCases } from "~/crud/contentEntry/useCases/index.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; @@ -94,6 +83,22 @@ import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import { CreateEntryUseCase } from "~/features/contentEntries/CreateEntry/index.js"; import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { + ListLatestEntriesUseCase, + ListPublishedEntriesUseCase, + ListDeletedEntriesUseCase +} from "~/features/contentEntries/ListEntries/index.js"; +import { ListEntriesUseCase } from "~/features/contentEntries/ListEntries/abstractions.js"; +import { GetEntriesByIdsUseCase } from "~/features/contentEntries/GetEntriesByIds/index.js"; +import { GetEntryByIdUseCase } from "~/features/contentEntries/GetEntryById/index.js"; +import { GetPublishedEntriesByIdsUseCase } from "~/features/contentEntries/GetPublishedEntriesByIds/index.js"; +import { GetLatestEntriesByIdsUseCase } from "~/features/contentEntries/GetLatestEntriesByIds/index.js"; +import { GetRevisionsByEntryIdUseCase } from "~/features/contentEntries/GetRevisionsByEntryId/index.js"; +import { GetEntryUseCase } from "~/features/contentEntries/GetEntry/index.js"; +import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntryRevision/index.js"; +import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; interface CreateContentEntryCrudParams { storageOperations: HeadlessCmsStorageOperations; @@ -264,87 +269,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const transformEntryFromStorageCallable = createTransformEntryCallable({ context }); - /** - * List entries - */ - const { - listEntriesUseCase, - listLatestUseCase, - listDeletedUseCase, - listPublishedUseCase, - getEntryUseCase - } = listEntriesUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.list, - accessControl, - topics: { onEntryBeforeList }, - context, - getIdentity: getSecurityIdentity - }); - - /** - * Get entries by ids - */ - const { getEntriesByIdsUseCase } = getEntriesByIdsUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getByIds, - accessControl - }); - - /** - * Get latest entries by ids - */ - const { getLatestEntriesByIdsUseCase } = getLatestEntriesByIdsUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getLatestByIds, - accessControl - }); - - /** - * Get published entries by ids - */ - const { getPublishedEntriesByIdsUseCase } = getPublishedEntriesByIdsUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getPublishedByIds, - accessControl - }); - - /** - * Get revisions by entryId - */ - const { getRevisionsByEntryIdUseCase } = getRevisionsByEntryIdUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getRevisions, - accessControl - }); - - /** - * Get revision by id - */ - const { getRevisionByIdUseCase } = getRevisionByIdUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getRevisionById - }); - - /** - * Get latest revision by entryId - */ - const { - getLatestRevisionByEntryIdUseCase, - getLatestRevisionByEntryIdWithDeletedUseCase, - getLatestRevisionByEntryIdDeletedUseCase - } = getLatestRevisionByEntryIdUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getLatestRevisionByEntryId - }); - - /** - * Get previous revision by entryId - */ - const { getPreviousRevisionByEntryIdUseCase } = getPreviousRevisionByEntryIdUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getPreviousRevision - }); /** * Get published revision by entryId @@ -354,22 +278,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm operation: storageOperations.entries.getPublishedRevisionByEntryId }); - /** - * Delete entry - */ - const { deleteEntryUseCase, moveEntryToBinUseCase, deleteEntryOperation } = deleteEntryUseCases( - { - deleteOperation: storageOperations.entries.delete, - moveToBinOperation: storageOperations.entries.moveToBin, - getEntry: getLatestRevisionByEntryIdUseCase, - getEntryWithDeleted: getLatestRevisionByEntryIdWithDeletedUseCase, - getIdentity: getSecurityIdentity, - topics: { onEntryBeforeDelete, onEntryAfterDelete, onEntryDeleteError }, - accessControl, - context - } - ); - /** * Restore entry from bin */ @@ -387,24 +295,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm context }); - const getEntryById = async ( - model: CmsModel, - id: string - ): Promise> => { - const where: CmsEntryListWhere = { - id - }; - await onEntryBeforeGet.publish({ - where, - model - }); - const [entry] = await getEntriesByIdsUseCase.execute(model, { ids: [id] }); - if (!entry) { - throw new NotFoundError(`Entry by ID "${id}" not found.`); - } - // TODO figure out without casting - return entry as CmsEntry; - }; const createEntry: CmsEntryContext["createEntry"] = async ( model: CmsModel, rawInput: CreateCmsEntryInput, @@ -451,29 +341,32 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm */ const { id: uniqueId } = parseIdentifier(sourceId); - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { - id: sourceId - }); - const latestStorageEntry = await getLatestRevisionByEntryIdUseCase.execute(model, { - id: uniqueId - }); + const useCase = context.container.resolve(GetRevisionByIdUseCase); + const originalResult = await useCase.execute(model, sourceId); - if (!originalStorageEntry) { + if (originalResult.isFail()) { throw new NotFoundError( `Entry "${sourceId}" of model "${model.modelId}" was not found.` ); } - if (!latestStorageEntry) { + const originalEntry = originalResult.value; + + const getLatestRevisionByEntryIdUseCase = context.container.resolve( + GetLatestRevisionByEntryIdUseCase + ); + + const latestStorageEntryResult = await getLatestRevisionByEntryIdUseCase.execute(model, { + id: uniqueId + }); + + if (latestStorageEntryResult.isFail()) { throw new NotFoundError( `Latest entry "${uniqueId}" of model "${model.modelId}" was not found.` ); } - /** - * We need to convert data from DB to its original form before using it further. - */ - const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); + const latestStorageEntry = latestStorageEntryResult.value; const { entry, input } = await createEntryRevisionFromData({ sourceId, @@ -531,8 +424,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm error: ex, entry, storageEntry, - originalEntry, - originalStorageEntry + originalEntry } ); } @@ -582,12 +474,13 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm /** * The entry we are going to update. */ - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { id }); + const useCase = context.container.resolve(GetRevisionByIdUseCase); + const entryResult = await useCase.execute(model, id); - if (!originalStorageEntry) { + if (entryResult.isFail()) { throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); } - originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); + originalEntry = entryResult.value; } await accessControl.ensureCanAccessEntry({ model, entry: originalEntry, rwd: "w" }); @@ -607,13 +500,14 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm /** * The entry we are going to move to another folder. */ - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { id }); + const useCase = context.container.resolve(GetRevisionByIdUseCase); + const result = await useCase.execute(model, id); - if (!originalStorageEntry) { + if (result.isFail()) { throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); } - const entry = await entryFromStorageTransform(context, model, originalStorageEntry); + const entry = result.value; await accessControl.ensureCanAccessEntry({ model, entry, rwd: "w" }); @@ -657,12 +551,13 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm /** * Fetch the entry from the storage. */ - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { id }); - if (!originalStorageEntry) { + const useCase = context.container.resolve(GetRevisionByIdUseCase); + const result = await useCase.execute(model, id); + if (result.isFail()) { throw new NotFoundError(`Entry "${id}" was not found!`); } - const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); + const originalEntry = result.value; await accessControl.ensureCanAccessEntry({ model, @@ -735,105 +630,11 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm model, revisionId ) => { - await accessControl.ensureCanAccessEntry({ model, rwd: "d" }); - - const { id: entryId, version } = parseIdentifier(revisionId); - - const storageEntryToDelete = await getRevisionByIdUseCase.execute(model, { - id: revisionId - }); - const latestStorageEntry = await getLatestRevisionByEntryIdUseCase.execute(model, { - id: entryId - }); - const storagePreviousEntry = await getPreviousRevisionByEntryIdUseCase.execute(model, { - entryId, - version: version as number - }); - - if (!storageEntryToDelete) { - throw new NotFoundError(`Entry "${revisionId}" was not found!`); - } + const useCase = context.container.resolve(DeleteEntryRevisionUseCase); + const result = await useCase.execute(model, revisionId); - const latestEntryRevisionId = latestStorageEntry ? latestStorageEntry.id : null; - - const entryToDelete = await entryFromStorageTransform(context, model, storageEntryToDelete); - - await accessControl.ensureCanAccessEntry({ model, entry: entryToDelete, rwd: "d" }); - - /** - * If targeted record is the latest entry record and there is no previous one, we need - * to run full delete with hooks. In this case, `deleteRevision` hooks are not fired. - */ - if (entryToDelete.id === latestEntryRevisionId && !storagePreviousEntry) { - return await deleteEntryOperation.execute(model, { entry: entryToDelete }); - } - /** - * If targeted record is the latest entry revision, set the previous one as the new latest. - */ - let entryToSetAsLatest: CmsEntry | null = null; - let storageEntryToSetAsLatest: CmsStorageEntry | null = null; - let updatedEntryToSetAsLatest: CmsEntry | null = null; - let storageUpdatedEntryToSetAsLatest: CmsStorageEntry | null = null; - - if (entryToDelete.id === latestEntryRevisionId && storagePreviousEntry) { - entryToSetAsLatest = await entryFromStorageTransform( - context, - model, - storagePreviousEntry - ); - storageEntryToSetAsLatest = storagePreviousEntry; - - /** - * Since we're setting a different revision as the latest, we need to update entry-level meta - * fields. The values are taken from the latest revision we're about to delete. The update of the - * new latest revision is performed within storage operations. - */ - const pickedEntryLevelMetaFields = pickEntryMetaFields( - entryToDelete, - isEntryLevelEntryMetaField - ); - - updatedEntryToSetAsLatest = { - ...entryToSetAsLatest, - ...pickedEntryLevelMetaFields - }; - - storageUpdatedEntryToSetAsLatest = { - ...storageEntryToSetAsLatest, - ...pickedEntryLevelMetaFields - }; - } - - try { - await onEntryRevisionBeforeDelete.publish({ - entry: entryToDelete, - model - }); - - await storageOperations.entries.deleteRevision(model, { - entry: entryToDelete, - storageEntry: storageEntryToDelete, - latestEntry: updatedEntryToSetAsLatest, - latestStorageEntry: storageUpdatedEntryToSetAsLatest - }); - - await onEntryRevisionAfterDelete.publish({ - entry: entryToDelete, - model - }); - } catch (ex) { - await onEntryRevisionDeleteError.publish({ - entry: entryToDelete, - model, - error: ex - }); - throw new WebinyError(ex.message, ex.code || "DELETE_REVISION_ERROR", { - error: ex, - entry: entryToDelete, - storageEntry: storageEntryToDelete, - latestEntry: updatedEntryToSetAsLatest, - latestStorageEntry: storageUpdatedEntryToSetAsLatest - }); + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } }; const deleteMultipleEntries: CmsEntryContext["deleteMultipleEntries"] = async ( @@ -911,43 +712,36 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm } }; - const deleteEntry: CmsEntryContext["deleteEntry"] = async (model, id, options = {}) => { - const { permanently = true } = options; - - /** - * If the 'permanently' flag is set to false, the entry must be moved to the bin; otherwise, deleted. - */ - if (!permanently) { - return await moveEntryToBinUseCase.execute(model, id, options); - } - - return await deleteEntryUseCase.execute(model, id, options); - }; const publishEntry = async ( model: CmsModel, id: string ) => { await accessControl.ensureCanAccessEntry({ model, pw: "p" }); - const originalStorageEntry = await getRevisionByIdUseCase.execute(model, { id }); + const useCase = context.container.resolve(GetRevisionByIdUseCase); + const result = await useCase.execute(model, id); - if (!originalStorageEntry) { + if (result.isFail()) { throw new NotFoundError(`Entry "${id}" in the model "${model.modelId}" was not found.`); } - const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); + const originalEntry = result.value; await accessControl.ensureCanAccessEntry({ model, entry: originalEntry, pw: "p" }); + const getLatestRevisionByEntryIdUseCase = context.container.resolve( + GetLatestRevisionByEntryIdUseCase + ); // We need the latest entry to get the latest entry-level meta fields. - const latestStorageEntry = await getLatestRevisionByEntryIdUseCase.execute(model, { + const latestStorageEntryResult = await getLatestRevisionByEntryIdUseCase.execute(model, { id: originalEntry.entryId }); - if (!latestStorageEntry) { + if (latestStorageEntryResult.isFail()) { throw new NotFoundError(`Entry "${id}" in the model "${model.modelId}" was not found.`); } + const latestStorageEntry = latestStorageEntryResult.value; const latestEntry = await entryFromStorageTransform(context, model, latestStorageEntry); const { entry } = await createPublishEntryData({ @@ -994,8 +788,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm error: ex, entry, storageEntry, - originalEntry, - originalStorageEntry + originalEntry } ); } @@ -1215,16 +1008,48 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.getEntriesByIds", async () => { - return getEntriesByIdsUseCase.execute(model, { ids }); + const useCase = context.container.resolve(GetEntriesByIdsUseCase); + const result = await useCase.execute(model, ids); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not get entries by IDs.", + error.code || "GET_ENTRIES_BY_IDS_ERROR", + { + error, + ids, + model + } + ); + } + + return result.value; } ); }, /** * Get a single entry by revision ID from the database. */ - async getEntryById(model, id) { + async getEntryById(model: CmsModel, id: string) { return context.benchmark.measure("headlessCms.crud.entries.getEntryById", async () => { - return getEntryById(model, id); + const useCase = context.container.resolve(GetEntryByIdUseCase); + const result = await useCase.execute(model, id); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || `Entry by ID "${id}" not found.`, + error.code || "GET_ENTRY_BY_ID_ERROR", + { + error, + id, + model + } + ); + } + + return result.value; }); }, /** @@ -1234,7 +1059,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.getPublishedEntriesByIds", async () => { - return getPublishedEntriesByIdsUseCase.execute(model, { ids }); + const useCase = context.container.resolve(GetPublishedEntriesByIdsUseCase); + const result = await useCase.execute(model, ids); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not get published entries by IDs.", + error.code || "GET_PUBLISHED_ENTRIES_BY_IDS_ERROR", + { + error, + ids, + model + } + ); + } + + return result.value; } ); }, @@ -1245,7 +1086,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.getLatestEntriesByIds", async () => { - return await getLatestEntriesByIdsUseCase.execute(model, { ids }); + const useCase = context.container.resolve(GetLatestEntriesByIdsUseCase); + const result = await useCase.execute(model, ids); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not get latest entries by IDs.", + error.code || "GET_LATEST_ENTRIES_BY_IDS_ERROR", + { + error, + ids, + model + } + ); + } + + return result.value; } ); }, @@ -1253,19 +1110,51 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.getEntryRevisions", async () => { - return getRevisionsByEntryIdUseCase.execute(model, { id: entryId }); + const useCase = context.container.resolve(GetRevisionsByEntryIdUseCase); + const result = await useCase.execute(model, entryId); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not get entry revisions.", + error.code || "GET_ENTRY_REVISIONS_ERROR", + { + error, + entryId, + model + } + ); + } + + return result.value; } ); }, /** * @internal */ - async getEntry( + async getEntry( model: CmsModel, params: CmsEntryGetParams ): Promise> { return context.benchmark.measure("headlessCms.crud.entries.getEntry", async () => { - return (await getEntryUseCase.execute(model, params)) as CmsEntry; + const useCase = context.container.resolve(GetEntryUseCase); + const result = await useCase.execute(model, params); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Entry not found!", + error.code || "GET_ENTRY_ERROR", + { + error, + params, + model + } + ); + } + + return result.value; }); }, /** @@ -1278,7 +1167,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm params: CmsEntryListParams ): Promise<[CmsEntry[], CmsEntryMeta]> { return context.benchmark.measure("headlessCms.crud.entries.listEntries", async () => { - return await listEntriesUseCase.execute(model, params); + const useCase = context.container.resolve(ListEntriesUseCase); + const result = await useCase.execute(model, params); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not list entries.", + error.code || "LIST_ENTRIES_ERROR", + { + error, + params, + model + } + ); + } + + return result.value; }); }, async listLatestEntries( @@ -1288,7 +1193,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.listLatestEntries", async () => { - return await listLatestUseCase.execute(model, params); + const useCase = context.container.resolve(ListLatestEntriesUseCase); + const result = await useCase.execute(model, params); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not list latest entries.", + error.code || "LIST_LATEST_ENTRIES_ERROR", + { + error, + params, + model + } + ); + } + + return result.value; } ); }, @@ -1299,7 +1220,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.listDeletedEntries", async () => { - return await listDeletedUseCase.execute(model, params); + const useCase = context.container.resolve(ListDeletedEntriesUseCase); + const result = await useCase.execute(model, params); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not list deleted entries.", + error.code || "LIST_DELETED_ENTRIES_ERROR", + { + error, + params, + model + } + ); + } + + return result.value; } ); }, @@ -1310,7 +1247,23 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.listPublishedEntries", async () => { - return await listPublishedUseCase.execute(model, params); + const useCase = context.container.resolve(ListPublishedEntriesUseCase); + const result = await useCase.execute(model, params); + + if (result.isFail()) { + const error = result.error; + throw new WebinyError( + error.message || "Could not list published entries.", + error.code || "LIST_PUBLISHED_ENTRIES_ERROR", + { + error, + params, + model + } + ); + } + + return result.value; } ); }, @@ -1367,8 +1320,17 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm ); }, async deleteEntry(model, entryId, options) { + const deleteEntryUseCase = context.container.resolve(DeleteEntryUseCase); return context.benchmark.measure("headlessCms.crud.entries.deleteEntry", async () => { - return deleteEntry(model, entryId, options); + const result = await deleteEntryUseCase.execute(model, entryId, options ?? {}); + + if (result.isFail()) { + throw new WebinyError( + result.error.message, + result.error.code, + result.error.data + ); + } }); }, async restoreEntryFromBin(model, entryId) { diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts index f11ad441751..69ff47c471a 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -2,15 +2,35 @@ import { createFeature } from "@webiny/feature/api"; import { CreateEntryFeature } from "./CreateEntry/feature.js"; import { UpdateEntryFeature } from "./UpdateEntry/feature.js"; import { GetRevisionByIdFeature } from "./GetRevisionById/feature.js"; +import { ListEntriesFeature } from "./ListEntries/feature.js"; +import { GetEntriesByIdsFeature } from "./GetEntriesByIds/feature.js"; +import { GetEntryByIdFeature } from "./GetEntryById/feature.js"; +import { GetPublishedEntriesByIdsFeature } from "./GetPublishedEntriesByIds/feature.js"; +import { GetLatestEntriesByIdsFeature } from "./GetLatestEntriesByIds/feature.js"; +import { GetRevisionsByEntryIdFeature } from "./GetRevisionsByEntryId/feature.js"; +import { GetPreviousRevisionByEntryIdFeature } from "./GetPreviousRevisionByEntryId/feature.js"; +import { GetEntryFeature } from "./GetEntry/feature.js"; +import { DeleteEntryFeature } from "./DeleteEntry/feature.js"; +import { DeleteEntryRevisionFeature } from "./DeleteEntryRevision/feature.js"; export const ContentEntriesFeature = createFeature({ name: "ContentEntries", register(container) { // Query features GetRevisionByIdFeature.register(container); + GetEntriesByIdsFeature.register(container); + GetEntryByIdFeature.register(container); + GetPublishedEntriesByIdsFeature.register(container); + GetLatestEntriesByIdsFeature.register(container); + GetRevisionsByEntryIdFeature.register(container); + GetPreviousRevisionByEntryIdFeature.register(container); + GetEntryFeature.register(container); + ListEntriesFeature.register(container); // Command features CreateEntryFeature.register(container); UpdateEntryFeature.register(container); + DeleteEntryFeature.register(container); + DeleteEntryRevisionFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts new file mode 100644 index 00000000000..077e672c459 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts @@ -0,0 +1,31 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; + +/** + * DeleteEntryRepository - Handles storage operations for permanently deleting entries. + */ +class DeleteEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor(private storageOperations: StorageOperations.Interface) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + await this.storageOperations.entries.delete(model, { entry }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const DeleteEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: DeleteEntryRepositoryImpl, + dependencies: [StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts new file mode 100644 index 00000000000..9ff2f21eae6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts @@ -0,0 +1,127 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteEntryUseCase as UseCaseAbstraction, MoveEntryToBinUseCase } from "./abstractions.js"; +import { DeleteEntryRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteEntryUseCase - Orchestrates permanent deletion of an entry. + * + * Responsibilities: + * - Apply access control + * - Get the entry to delete by ID + * - TODO: Handle force delete (for cleanup when entry might not exist in storage), and `permanently` + * - Publish domain events + * - Delegate to repository for storage operations + */ +class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private moveEntryToBin: MoveEntryToBinUseCase.Interface, + private repository: DeleteEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdIncludingDeletedUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + options: CmsDeleteEntryOptions + ): Promise> { + const { permanently = true } = options; + + if (!permanently) { + return this.moveEntryToBin.execute(model, id); + } + + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Get the entry to delete by ID + const getResult = await this.getLatestRevision.execute(model, { id }); + + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + + const entryToDelete = getResult.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: entryToDelete, + rwd: "d" + }); + + if (!canAccessEntry) { + return Result.fail(new NotAuthorizedError()); + } + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeDeleteEvent({ + entry: entryToDelete, + model, + permanent: true + }) + ); + + // Delegate to repository + const result = await this.repository.execute(model, entryToDelete); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryDeleteErrorEvent({ + entry: entryToDelete, + model, + permanent: true, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterDeleteEvent({ + entry: entryToDelete, + model, + permanent: true + }) + ); + + return Result.ok(); + } catch (error) { + await this.eventPublisher.publish( + new EntryDeleteErrorEvent({ + entry: entryToDelete, + model, + permanent: true, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const DeleteEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: DeleteEntryUseCaseImpl, + dependencies: [ + MoveEntryToBinUseCase, + DeleteEntryRepository, + AccessControl, + GetLatestRevisionByEntryIdIncludingDeletedUseCase, + EventPublisher + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts new file mode 100644 index 00000000000..15609acfe3b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts @@ -0,0 +1,43 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { MoveEntryToBinRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; + +/** + * MoveEntryToBinRepository - Handles storage operations for soft deleting entries. + */ +class MoveEntryToBinRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute(params: { + model: CmsModel; + entry: CmsEntry; + }): Promise> { + const { model, entry } = params; + + try { + const storageEntry = await this.entryToStorageTransform(model, entry); + + await this.storageOperations.entries.moveToBin(model, { + entry, + storageEntry + }); + + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const MoveEntryToBinRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: MoveEntryToBinRepositoryImpl, + dependencies: [EntryToStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts new file mode 100644 index 00000000000..111cd7917e1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts @@ -0,0 +1,149 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { MoveEntryToBinUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { MoveEntryToBinRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { getDate } from "~/utils/date.js"; +import { getIdentity } from "~/utils/identity.js"; +import { ROOT_FOLDER } from "~/constants.js"; + +/** + * MoveEntryToBinUseCase - Orchestrates soft deletion of an entry (move to bin). + * + * Responsibilities: + * - Apply access control + * - Get the entry to delete by ID + * - Mark entry as deleted (wbyDeleted = true) + * - Update deletion metadata + * - Publish domain events with permanent: false + * - Delegate to repository for storage operations + */ +class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: MoveEntryToBinRepository.Interface, + private accessControl: AccessControl.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface, + private identityContext: IdentityContext.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute(model: CmsModel, id: string): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Get the entry to delete by ID + const getResult = await this.getLatestRevision.execute(model, { id }); + + if (getResult.isFail()) { + return Result.fail(new EntryNotFoundError(id)); + } + + const originalEntry = getResult.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + rwd: "d" + }); + + if (!canAccessEntry) { + return Result.fail(new NotAuthorizedError()); + } + + // Create the deleted entry data + const currentDateTime = new Date().toISOString(); + const currentIdentity = this.identityContext.getIdentity(); + + const entryToDelete = { + ...originalEntry, + wbyDeleted: true, + + // Entry location fields - move to root folder + location: { + folderId: ROOT_FOLDER + }, + binOriginalFolderId: originalEntry.location?.folderId, + + // Entry-level meta fields + deletedOn: getDate(currentDateTime, null), + deletedBy: getIdentity(currentIdentity, null), + + // Revision-level meta fields + revisionDeletedOn: getDate(currentDateTime, null), + revisionDeletedBy: getIdentity(currentIdentity, null) + }; + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeDeleteEvent({ + entry: entryToDelete, + model, + permanent: false + }) + ); + + // Delegate to repository + const result = await this.repository.execute({ + model, + entry: entryToDelete + }); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryDeleteErrorEvent({ + entry: entryToDelete, + model, + permanent: false, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterDeleteEvent({ + entry: entryToDelete, + model, + permanent: false + }) + ); + + return Result.ok(); + } catch (error) { + await this.eventPublisher.publish( + new EntryDeleteErrorEvent({ + entry: entryToDelete, + model, + permanent: false, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const MoveEntryToBinUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: MoveEntryToBinUseCaseImpl, + dependencies: [ + MoveEntryToBinRepository, + AccessControl, + GetLatestRevisionByEntryIdUseCase, + IdentityContext, + EventPublisher + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts new file mode 100644 index 00000000000..ac6e3c9c27b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts @@ -0,0 +1,113 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteEntry Use Case - Permanently deletes an entry from the database. + * This is a hard delete that removes all traces of the entry. + */ +export interface IDeleteEntryUseCase { + execute( + model: CmsModel, + id: string, + options?: CmsDeleteEntryOptions + ): Promise>; +} + +export interface IDeleteEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IDeleteEntryUseCaseErrors[keyof IDeleteEntryUseCaseErrors]; + +export const DeleteEntryUseCase = createAbstraction("DeleteEntryUseCase"); + +export namespace DeleteEntryUseCase { + export type Interface = IDeleteEntryUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before delete event + */ +export interface EntryBeforeDeletePayload { + entry: CmsEntry; + model: CmsModel; + permanent: boolean; +} + +/** + * Payload for after delete event + */ +export interface EntryAfterDeletePayload { + entry: CmsEntry; + model: CmsModel; + permanent: boolean; +} + +/** + * Payload for delete error event + */ +export interface EntryDeleteErrorPayload { + entry: CmsEntry; + model: CmsModel; + permanent: boolean; + error: Error; +} + +/** + * DeleteEntryRepository - Handles storage operations for permanently deleting entries. + */ +export interface IDeleteEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IDeleteEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IDeleteEntryRepositoryErrors[keyof IDeleteEntryRepositoryErrors]; + +export const DeleteEntryRepository = + createAbstraction("DeleteEntryRepository"); + +export namespace DeleteEntryRepository { + export type Interface = IDeleteEntryRepository; + export type Error = RepositoryError; +} + +/** + * MoveEntryToBin Use Case - Soft deletes an entry by marking it as deleted. + * This moves the entry to the bin (trash) instead of permanently deleting it. + */ +export interface IMoveEntryToBinUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export const MoveEntryToBinUseCase = + createAbstraction("MoveEntryToBinUseCase"); + +export namespace MoveEntryToBinUseCase { + export type Interface = IMoveEntryToBinUseCase; + export type Error = UseCaseError; +} + +/** + * MoveEntryToBinRepository - Handles storage operations for soft deleting entries. + */ +export interface IMoveEntryToBinRepository { + execute(params: { model: CmsModel; entry: CmsEntry }): Promise>; +} + +export const MoveEntryToBinRepository = createAbstraction( + "MoveEntryToBinRepository" +); + +export namespace MoveEntryToBinRepository { + export type Interface = IMoveEntryToBinRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts new file mode 100644 index 00000000000..319e7ef88ab --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts @@ -0,0 +1,55 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "../abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; +import { parseIdentifier } from "@webiny/utils"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * Handles force delete logic for cleanup scenarios. + * + * When force=true and entry doesn't exist, this decorator directly calls storage + * operations to clean up any orphaned records (e.g., in Elasticsearch when DynamoDB + * record is already deleted). + */ +class ForceDeleteDecoratorImpl implements DeleteEntryUseCase.Interface { + constructor( + private storageOperations: StorageOperations.Interface, + private decoratee: DeleteEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + options: CmsDeleteEntryOptions = {} + ): Promise> { + const { force = false } = options; + + const result = await this.decoratee.execute(model, id, options); + + if (force && result.isFail() && result.error.code === "ENTRY_NOT_FOUND") { + const { id: entryId } = parseIdentifier(id); + + try { + // Not the nicest way to do it, but we need to revisit storage operations anyway. + await this.storageOperations.entries.delete(model, { + entry: { + id, + entryId + } + } as any); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } + + return result; + } +} + +export const ForceDeleteDecorator = createDecorator({ + abstraction: DeleteEntryUseCase, + decorator: ForceDeleteDecoratorImpl, + dependencies: [StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts new file mode 100644 index 00000000000..c7640ec2092 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforeDeletePayload, + EntryAfterDeletePayload, + EntryDeleteErrorPayload +} from "./abstractions.js"; + +/** + * Before delete entry event + */ +export class EntryBeforeDeleteEvent extends DomainEvent { + eventType = "entry.beforeDelete" as const; + + getHandlerAbstraction() { + return EntryBeforeDeleteHandler; + } +} + +export const EntryBeforeDeleteHandler = createAbstraction>( + "EntryBeforeDeleteHandler" +); + +export namespace EntryBeforeDeleteHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeDeleteEvent; +} + +/** + * After delete entry event + */ +export class EntryAfterDeleteEvent extends DomainEvent { + eventType = "entry.afterDelete" as const; + + getHandlerAbstraction() { + return EntryAfterDeleteHandler; + } +} + +export const EntryAfterDeleteHandler = createAbstraction>( + "EntryAfterDeleteHandler" +); + +export namespace EntryAfterDeleteHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterDeleteEvent; +} + +/** + * Delete entry error event + */ +export class EntryDeleteErrorEvent extends DomainEvent { + eventType = "entry.deleteError" as const; + + getHandlerAbstraction() { + return EntryDeleteErrorHandler; + } +} + +export const EntryDeleteErrorHandler = createAbstraction>( + "EntryDeleteErrorHandler" +); + +export namespace EntryDeleteErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryDeleteErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts new file mode 100644 index 00000000000..bc368cc453b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts @@ -0,0 +1,22 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "./DeleteEntryUseCase.js"; +import { DeleteEntryRepository } from "./DeleteEntryRepository.js"; +import { MoveEntryToBinUseCase } from "./MoveEntryToBinUseCase.js"; +import { MoveEntryToBinRepository } from "./MoveEntryToBinRepository.js"; +import { ForceDeleteDecorator } from "./decorators/ForceDeleteDecorator.js"; + +export const DeleteEntryFeature = createFeature({ + name: "DeleteEntry", + register(container) { + // Register repositories (singleton scope) + container.register(DeleteEntryRepository).inSingletonScope(); + container.register(MoveEntryToBinRepository).inSingletonScope(); + + // Register use cases (transient scope) + container.register(DeleteEntryUseCase); + container.register(MoveEntryToBinUseCase); + + // Register decorators + container.registerDecorator(ForceDeleteDecorator); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts new file mode 100644 index 00000000000..0a373e4cbae --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts @@ -0,0 +1,11 @@ +export { + DeleteEntryUseCase, + DeleteEntryRepository, + MoveEntryToBinUseCase, + MoveEntryToBinRepository +} from "./abstractions.js"; +export { + EntryBeforeDeleteHandler, + EntryAfterDeleteHandler, + EntryDeleteErrorHandler +} from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts new file mode 100644 index 00000000000..a4a8df13d12 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts @@ -0,0 +1,63 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteEntryRevisionRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; +import { isEntryLevelEntryMetaField, pickEntryMetaFields } from "~/constants.js"; + +/** + * DeleteEntryRevisionRepository - Handles storage operations for deleting entry revisions. + */ +class DeleteEntryRevisionRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute(params: { + model: CmsModel; + entry: CmsEntry; + latestEntry: CmsEntry | null; + }): Promise> { + const { model, entry, latestEntry } = params; + + try { + const storageEntry = await this.entryToStorageTransform(model, entry); + + let storageLatestEntry = null; + if (latestEntry) { + // Pick entry-level meta fields from the deleted entry to update the new latest + const pickedEntryLevelMetaFields = pickEntryMetaFields( + entry, + isEntryLevelEntryMetaField + ); + + const updatedLatestEntry = { + ...latestEntry, + ...pickedEntryLevelMetaFields + }; + + storageLatestEntry = await this.entryToStorageTransform(model, updatedLatestEntry); + } + + await this.storageOperations.entries.deleteRevision(model, { + entry, + storageEntry, + latestEntry: latestEntry, + latestStorageEntry: storageLatestEntry + }); + + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const DeleteEntryRevisionRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: DeleteEntryRevisionRepositoryImpl, + dependencies: [EntryToStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts new file mode 100644 index 00000000000..c8b3acc2cc9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -0,0 +1,168 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteEntryRevisionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { DeleteEntryRevisionRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetPreviousRevisionByEntryIdUseCase } from "~/features/contentEntries/GetPreviousRevisionByEntryId/index.js"; +import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { + EntryRevisionBeforeDeleteEvent, + EntryRevisionAfterDeleteEvent, + EntryRevisionDeleteErrorEvent +} from "./events.js"; +import { parseIdentifier } from "@webiny/utils"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteEntryRevisionUseCase - Orchestrates deletion of a specific entry revision. + * + * Responsibilities: + * - Parse revision ID to extract entry ID and version + * - Apply access control + * - Get the revision to delete + * - Determine if this is the latest revision + * - If latest and no previous revision exists, perform full entry delete + * - If latest and previous exists, set previous as new latest + * - Publish domain events + * - Delegate to repository for storage operations + */ +class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: DeleteEntryRevisionRepository.Interface, + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdIncludingDeletedUseCase.Interface, + private getPreviousRevision: GetPreviousRevisionByEntryIdUseCase.Interface, + private deleteEntry: DeleteEntryUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + model: CmsModel, + revisionId: string + ): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + const { id: entryId, version } = parseIdentifier(revisionId); + + // Get the revision to delete + const getRevisionResult = await this.getRevisionById.execute(model, revisionId); + if (getRevisionResult.isFail()) { + return Result.fail(getRevisionResult.error); + } + + const entryToDelete = getRevisionResult.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: entryToDelete, + rwd: "d" + }); + + if (!canAccessEntry) { + return Result.fail(new NotAuthorizedError()); + } + + // Get the latest revision + const latestRevisionResult = await this.getLatestRevision.execute(model, { id: entryId }); + if (latestRevisionResult.isFail()) { + return Result.fail(latestRevisionResult.error); + } + + const latestRevision = latestRevisionResult.value; + const latestRevisionId = latestRevision?.id || null; + + // Get the previous revision + const previousRevisionResult = await this.getPreviousRevision.execute(model, { + entryId, + version: version as number + }); + if (previousRevisionResult.isFail()) { + return Result.fail(previousRevisionResult.error); + } + + const previousRevision = previousRevisionResult.value; + + // If targeted record is the latest entry record and there is no previous one, + // we need to run full delete with hooks + if (entryToDelete.id === latestRevisionId && !previousRevision) { + return await this.deleteEntry.execute(model, revisionId, {}); + } + + // Determine the entry to set as latest (if deleting current latest) + let latestEntry = null; + if (entryToDelete.id === latestRevisionId && previousRevision) { + latestEntry = previousRevision; + } + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryRevisionBeforeDeleteEvent({ + entry: entryToDelete, + model + }) + ); + + // Delegate to repository + const result = await this.repository.execute({ + model, + entry: entryToDelete, + latestEntry + }); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryRevisionDeleteErrorEvent({ + entry: entryToDelete, + model, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryRevisionAfterDeleteEvent({ + entry: entryToDelete, + model + }) + ); + + return Result.ok(); + } catch (error) { + await this.eventPublisher.publish( + new EntryRevisionDeleteErrorEvent({ + entry: entryToDelete, + model, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const DeleteEntryRevisionUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: DeleteEntryRevisionUseCaseImpl, + dependencies: [ + DeleteEntryRevisionRepository, + AccessControl, + GetRevisionByIdUseCase, + GetLatestRevisionByEntryIdIncludingDeletedUseCase, + GetPreviousRevisionByEntryIdUseCase, + DeleteEntryUseCase, + EventPublisher + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts new file mode 100644 index 00000000000..138ee5a90b2 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts @@ -0,0 +1,81 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteEntryRevision Use Case - Deletes a specific revision of an entry. + * Handles special cases like deleting the latest revision. + */ +export interface IDeleteEntryRevisionUseCase { + execute(model: CmsModel, revisionId: string): Promise>; +} + +export interface IDeleteEntryRevisionUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IDeleteEntryRevisionUseCaseErrors[keyof IDeleteEntryRevisionUseCaseErrors]; + +export const DeleteEntryRevisionUseCase = createAbstraction( + "DeleteEntryRevisionUseCase" +); + +export namespace DeleteEntryRevisionUseCase { + export type Interface = IDeleteEntryRevisionUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before delete event + */ +export interface EntryRevisionBeforeDeletePayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for after delete event + */ +export interface EntryRevisionAfterDeletePayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for delete error event + */ +export interface EntryRevisionDeleteErrorPayload { + entry: CmsEntry; + model: CmsModel; + error: Error; +} + +/** + * DeleteEntryRevisionRepository - Handles storage operations for deleting entry revisions. + */ +export interface IDeleteEntryRevisionRepository { + execute(params: { + model: CmsModel; + entry: CmsEntry; + latestEntry: CmsEntry | null; + }): Promise>; +} + +export interface IDeleteEntryRevisionRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IDeleteEntryRevisionRepositoryErrors[keyof IDeleteEntryRevisionRepositoryErrors]; + +export const DeleteEntryRevisionRepository = createAbstraction( + "DeleteEntryRevisionRepository" +); + +export namespace DeleteEntryRevisionRepository { + export type Interface = IDeleteEntryRevisionRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts new file mode 100644 index 00000000000..5e29bfa35ca --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryRevisionBeforeDeletePayload, + EntryRevisionAfterDeletePayload, + EntryRevisionDeleteErrorPayload +} from "./abstractions.js"; + +/** + * Before delete revision event + */ +export class EntryRevisionBeforeDeleteEvent extends DomainEvent { + eventType = "entryRevision.beforeDelete" as const; + + getHandlerAbstraction() { + return EntryRevisionBeforeDeleteHandler; + } +} + +export const EntryRevisionBeforeDeleteHandler = createAbstraction< + IEventHandler +>("EntryRevisionBeforeDeleteHandler"); + +export namespace EntryRevisionBeforeDeleteHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionBeforeDeleteEvent; +} + +/** + * After delete revision event + */ +export class EntryRevisionAfterDeleteEvent extends DomainEvent { + eventType = "entryRevision.afterDelete" as const; + + getHandlerAbstraction() { + return EntryRevisionAfterDeleteHandler; + } +} + +export const EntryRevisionAfterDeleteHandler = createAbstraction< + IEventHandler +>("EntryRevisionAfterDeleteHandler"); + +export namespace EntryRevisionAfterDeleteHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionAfterDeleteEvent; +} + +/** + * Delete revision error event + */ +export class EntryRevisionDeleteErrorEvent extends DomainEvent { + eventType = "entryRevision.deleteError" as const; + + getHandlerAbstraction() { + return EntryRevisionDeleteErrorHandler; + } +} + +export const EntryRevisionDeleteErrorHandler = createAbstraction< + IEventHandler +>("EntryRevisionDeleteErrorHandler"); + +export namespace EntryRevisionDeleteErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionDeleteErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts new file mode 100644 index 00000000000..215cbdc90e0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteEntryRevisionUseCase } from "./DeleteEntryRevisionUseCase.js"; +import { DeleteEntryRevisionRepository } from "./DeleteEntryRevisionRepository.js"; + +export const DeleteEntryRevisionFeature = createFeature({ + name: "DeleteEntryRevision", + register(container) { + // Register repository (singleton scope) + container.register(DeleteEntryRevisionRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(DeleteEntryRevisionUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts new file mode 100644 index 00000000000..1f99c4488c2 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts @@ -0,0 +1,6 @@ +export { DeleteEntryRevisionUseCase, DeleteEntryRevisionRepository } from "./abstractions.js"; +export { + EntryRevisionBeforeDeleteHandler, + EntryRevisionAfterDeleteHandler, + EntryRevisionDeleteErrorHandler +} from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts new file mode 100644 index 00000000000..6eb7e004777 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetEntriesByIdsRepository - Fetches entries by IDs from storage and transforms them. + * Returns array of entries. + */ +class GetEntriesByIdsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryAbstraction.Error>> { + try { + const result = await this.storageOperations.entries.getByIds(model, { ids }); + + // Transform storage entries to domain entries + const items = await Promise.all( + result.map(async entry => { + return this.entryFromStorageTransform(model, entry); + }) + ); + + return Result.ok(items as CmsEntry[]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetEntriesByIdsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetEntriesByIdsRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts new file mode 100644 index 00000000000..0a5ff2b0444 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetEntriesByIdsRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetEntriesByIdsUseCase - Orchestrates fetching entries by IDs with access control. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + */ +class GetEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetEntriesByIdsRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, ids); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetEntriesByIdsUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetEntriesByIdsUseCaseImpl, + dependencies: [GetEntriesByIdsRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts new file mode 100644 index 00000000000..df9a41583ed --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetEntriesByIds Use Case - Fetches multiple entries by their exact revision IDs. + * Returns array of entries (excludes deleted entries via decorator). + */ +export interface IGetEntriesByIdsUseCase { + execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseError>>; +} + +export interface IGetEntriesByIdsUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IGetEntriesByIdsUseCaseErrors[keyof IGetEntriesByIdsUseCaseErrors]; + +export const GetEntriesByIdsUseCase = createAbstraction( + "GetEntriesByIdsUseCase" +); + +export namespace GetEntriesByIdsUseCase { + export type Interface = IGetEntriesByIdsUseCase; + export type Error = UseCaseError; +} + +/** + * GetEntriesByIdsRepository - Fetches entries from storage by IDs and transforms them. + */ +export interface IGetEntriesByIdsRepository { + execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryError>>; +} + +export interface IGetEntriesByIdsRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IGetEntriesByIdsRepositoryErrors[keyof IGetEntriesByIdsRepositoryErrors]; + +export const GetEntriesByIdsRepository = createAbstraction( + "GetEntriesByIdsRepository" +); + +export namespace GetEntriesByIdsRepository { + export type Interface = IGetEntriesByIdsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts new file mode 100644 index 00000000000..5703031cfe0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts @@ -0,0 +1,38 @@ +import { createDecorator } from "@webiny/feature/api"; +import { GetEntriesByIdsUseCase } from "../abstractions.js"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; + +/** + * GetEntriesByIdsNotDeletedDecorator - Filters out deleted entries. + * + * This decorator wraps the GetEntriesByIdsUseCase and filters out + * entries marked as deleted (wbyDeleted flag). + */ +class GetEntriesByIdsNotDeletedDecoratorImpl implements GetEntriesByIdsUseCase.Interface { + constructor(private decoratee: GetEntriesByIdsUseCase.Interface) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], GetEntriesByIdsUseCase.Error>> { + const result = await this.decoratee.execute(model, ids); + + if (result.isFail()) { + return result; + } + + const entries = result.value; + + // Filter out deleted entries + const nonDeletedEntries = entries.filter(entry => !entry.wbyDeleted); + + return Result.ok(nonDeletedEntries); + } +} + +export const GetEntriesByIdsNotDeletedDecorator = createDecorator({ + abstraction: GetEntriesByIdsUseCase, + decorator: GetEntriesByIdsNotDeletedDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts new file mode 100644 index 00000000000..deb2c023d24 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts @@ -0,0 +1,13 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetEntriesByIdsUseCase } from "./GetEntriesByIdsUseCase.js"; +import { GetEntriesByIdsRepository } from "./GetEntriesByIdsRepository.js"; +import { GetEntriesByIdsNotDeletedDecorator } from "./decorators/GetEntriesByIdsNotDeletedDecorator.js"; + +export const GetEntriesByIdsFeature = createFeature({ + name: "GetEntriesByIds", + register(container) { + container.register(GetEntriesByIdsUseCase); + container.register(GetEntriesByIdsRepository).inSingletonScope(); + container.registerDecorator(GetEntriesByIdsNotDeletedDecorator); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts new file mode 100644 index 00000000000..4be97c6fc9e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts @@ -0,0 +1 @@ +export { GetEntriesByIdsUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts new file mode 100644 index 00000000000..7f1be8a3310 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListEntriesUseCase } from "../ListEntries/abstractions.js"; +import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * GetEntryUseCase - Gets a single entry by query. + * Delegates to ListEntriesUseCase with limit 1 and returns first entry. + */ +class GetEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private listEntriesUseCase: ListEntriesUseCase.Interface) {} + + async execute( + model: CmsModel, + params: CmsEntryGetParams + ): Promise, UseCaseAbstraction.Error>> { + const listParams = { + ...params, + limit: 1 + }; + + const result = await this.listEntriesUseCase.execute(model, listParams); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const [entries] = result.value; + const entry = entries[0]; + + if (!entry) { + return Result.fail(new EntryNotFoundError("Entry not found!")); + } + + return Result.ok(entry); + } +} + +export const GetEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetEntryUseCaseImpl, + dependencies: [ListEntriesUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts new file mode 100644 index 00000000000..8d1baf8ae43 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts @@ -0,0 +1,31 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetEntry Use Case - Gets a single entry by query parameters (where + sort). + * Uses list operation with limit 1 and returns first result or NotFoundError. + */ +export interface IGetEntryUseCase { + execute( + model: CmsModel, + params: CmsEntryGetParams + ): Promise, UseCaseError>>; +} + +export interface IGetEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IGetEntryUseCaseErrors[keyof IGetEntryUseCaseErrors]; + +export const GetEntryUseCase = createAbstraction("GetEntryUseCase"); + +export namespace GetEntryUseCase { + export type Interface = IGetEntryUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts new file mode 100644 index 00000000000..429a21a5e49 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetEntryUseCase } from "./GetEntryUseCase.js"; + +export const GetEntryFeature = createFeature({ + name: "GetEntry", + register(container) { + container.register(GetEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts new file mode 100644 index 00000000000..4c31e91d9c4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts @@ -0,0 +1 @@ +export { GetEntryUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts new file mode 100644 index 00000000000..51724271aa2 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts @@ -0,0 +1,40 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetEntryByIdUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetEntriesByIdsUseCase } from "../GetEntriesByIds/abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * GetEntryByIdUseCase - Gets a single entry by ID. + * Delegates to GetEntriesByIdsUseCase and returns the first entry. + */ +class GetEntryByIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private getEntriesByIdsUseCase: GetEntriesByIdsUseCase.Interface) {} + + async execute( + model: CmsModel, + id: string + ): Promise, UseCaseAbstraction.Error>> { + const result = await this.getEntriesByIdsUseCase.execute(model, [id]); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const entries = result.value; + const entry = entries[0]; + + if (!entry) { + return Result.fail(new EntryNotFoundError(id)); + } + + return Result.ok(entry); + } +} + +export const GetEntryByIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetEntryByIdUseCaseImpl, + dependencies: [GetEntriesByIdsUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts new file mode 100644 index 00000000000..e7a4e91baf9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts @@ -0,0 +1,31 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetEntryById Use Case - Fetches a single entry by its exact revision ID. + * Returns entry or fails with EntryNotFoundError if not found or deleted. + */ +export interface IGetEntryByIdUseCase { + execute( + model: CmsModel, + id: string + ): Promise, UseCaseError>>; +} + +export interface IGetEntryByIdUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IGetEntryByIdUseCaseErrors[keyof IGetEntryByIdUseCaseErrors]; + +export const GetEntryByIdUseCase = createAbstraction("GetEntryByIdUseCase"); + +export namespace GetEntryByIdUseCase { + export type Interface = IGetEntryByIdUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts new file mode 100644 index 00000000000..19b0236cb95 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "./GetEntryByIdUseCase.js"; + +export const GetEntryByIdFeature = createFeature({ + name: "GetEntryById", + register(container) { + container.register(GetEntryByIdUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts new file mode 100644 index 00000000000..38aba0c5340 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts @@ -0,0 +1 @@ +export { GetEntryByIdUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts new file mode 100644 index 00000000000..7389d7eb0a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetLatestEntriesByIdsRepository - Fetches latest entries by entry IDs from storage. + * Returns array of latest entries. + */ +class GetLatestEntriesByIdsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryAbstraction.Error>> { + try { + const result = await this.storageOperations.entries.getLatestByIds(model, { ids }); + + // Transform storage entries to domain entries + const items = await Promise.all( + result.map(async entry => { + return this.entryFromStorageTransform(model, entry); + }) + ); + + return Result.ok(items as CmsEntry[]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetLatestEntriesByIdsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetLatestEntriesByIdsRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts new file mode 100644 index 00000000000..d22d464f98e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetLatestEntriesByIdsRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetLatestEntriesByIdsUseCase - Orchestrates fetching latest entries by IDs. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + */ +class GetLatestEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetLatestEntriesByIdsRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, ids); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetLatestEntriesByIdsUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetLatestEntriesByIdsUseCaseImpl, + dependencies: [GetLatestEntriesByIdsRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts new file mode 100644 index 00000000000..998cf92f72e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetLatestEntriesByIds Use Case - Fetches latest revisions by entry IDs. + * Returns array of latest entries (excludes deleted entries via decorator). + */ +export interface IGetLatestEntriesByIdsUseCase { + execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseError>>; +} + +export interface IGetLatestEntriesByIdsUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IGetLatestEntriesByIdsUseCaseErrors[keyof IGetLatestEntriesByIdsUseCaseErrors]; + +export const GetLatestEntriesByIdsUseCase = createAbstraction( + "GetLatestEntriesByIdsUseCase" +); + +export namespace GetLatestEntriesByIdsUseCase { + export type Interface = IGetLatestEntriesByIdsUseCase; + export type Error = UseCaseError; +} + +/** + * GetLatestEntriesByIdsRepository - Fetches latest entries from storage by entry IDs. + */ +export interface IGetLatestEntriesByIdsRepository { + execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryError>>; +} + +export interface IGetLatestEntriesByIdsRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IGetLatestEntriesByIdsRepositoryErrors[keyof IGetLatestEntriesByIdsRepositoryErrors]; + +export const GetLatestEntriesByIdsRepository = createAbstraction( + "GetLatestEntriesByIdsRepository" +); + +export namespace GetLatestEntriesByIdsRepository { + export type Interface = IGetLatestEntriesByIdsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts new file mode 100644 index 00000000000..18131b87a05 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts @@ -0,0 +1,38 @@ +import { createDecorator } from "@webiny/feature/api"; +import { GetLatestEntriesByIdsUseCase } from "../abstractions.js"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; + +/** + * GetLatestEntriesByIdsNotDeletedDecorator - Filters out deleted entries. + * + * This decorator wraps the GetLatestEntriesByIdsUseCase and filters out + * entries marked as deleted (wbyDeleted flag). + */ +class GetLatestEntriesByIdsNotDeletedDecoratorImpl implements GetLatestEntriesByIdsUseCase.Interface { + constructor(private decoratee: GetLatestEntriesByIdsUseCase.Interface) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], GetLatestEntriesByIdsUseCase.Error>> { + const result = await this.decoratee.execute(model, ids); + + if (result.isFail()) { + return result; + } + + const entries = result.value; + + // Filter out deleted entries + const nonDeletedEntries = entries.filter(entry => !entry.wbyDeleted); + + return Result.ok(nonDeletedEntries); + } +} + +export const GetLatestEntriesByIdsNotDeletedDecorator = createDecorator({ + abstraction: GetLatestEntriesByIdsUseCase, + decorator: GetLatestEntriesByIdsNotDeletedDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts new file mode 100644 index 00000000000..f36fb87cbda --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts @@ -0,0 +1,13 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetLatestEntriesByIdsUseCase } from "./GetLatestEntriesByIdsUseCase.js"; +import { GetLatestEntriesByIdsRepository } from "./GetLatestEntriesByIdsRepository.js"; +import { GetLatestEntriesByIdsNotDeletedDecorator } from "./decorators/GetLatestEntriesByIdsNotDeletedDecorator.js"; + +export const GetLatestEntriesByIdsFeature = createFeature({ + name: "GetLatestEntriesByIds", + register(container) { + container.register(GetLatestEntriesByIdsUseCase); + container.register(GetLatestEntriesByIdsRepository).inSingletonScope(); + container.registerDecorator(GetLatestEntriesByIdsNotDeletedDecorator); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts new file mode 100644 index 00000000000..c2bc6a68c7e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts @@ -0,0 +1 @@ +export { GetLatestEntriesByIdsUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts new file mode 100644 index 00000000000..1705de7b0f1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestRevisionByEntryIdBaseUseCase as BaseUseCaseAbstraction } from "./abstractions.js"; +import { GetLatestRevisionByEntryIdRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * Orchestrates fetching latest revision by entry ID. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + * - Returns entry regardless of deleted state (decorators handle filtering) + */ +class GetLatestRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction.Interface { + constructor( + private repository: GetLatestRevisionByEntryIdRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, BaseUseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, params); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const BaseUseCase = createImplementation({ + abstraction: BaseUseCaseAbstraction, + implementation: GetLatestRevisionByEntryIdUseCaseImpl, + dependencies: [GetLatestRevisionByEntryIdRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts new file mode 100644 index 00000000000..3966670fa09 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetLatestRevisionByEntryIdRepository - Fetches latest revision by entry ID from storage. + * Returns the latest revision for a given entry (includes deleted entries). + */ +class GetLatestRevisionByEntryIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, RepositoryAbstraction.Error>> { + try { + const entry = await this.storageOperations.entries.getLatestRevisionByEntryId(model, params); + + if (!entry) { + return Result.fail(new EntryNotFoundError(params.id)); + } + + // Transform storage entry to domain entry + const transformedEntry = await this.entryFromStorageTransform(model, entry); + + return Result.ok(transformedEntry as CmsEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetLatestRevisionByEntryIdRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetLatestRevisionByEntryIdRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts new file mode 100644 index 00000000000..bebfa42a3f4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts @@ -0,0 +1,109 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * Base internal use case - returns entry regardless of deleted state. + * This is used internally by the three public variations. + */ +export interface IGetLatestRevisionByEntryIdBaseUseCase { + execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, UseCaseError>>; +} + +export interface IGetLatestRevisionByEntryIdUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = + IGetLatestRevisionByEntryIdUseCaseErrors[keyof IGetLatestRevisionByEntryIdUseCaseErrors]; + +export const GetLatestRevisionByEntryIdBaseUseCase = + createAbstraction( + "GetLatestRevisionByEntryIdBaseUseCase" + ); + +export namespace GetLatestRevisionByEntryIdBaseUseCase { + export type Interface = IGetLatestRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * Public variation 1: Returns non-deleted revision only + */ +export const GetLatestRevisionByEntryIdUseCase = + createAbstraction("GetLatestRevisionByEntryIdUseCase"); + +export namespace GetLatestRevisionByEntryIdUseCase { + export type Interface = IGetLatestRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * Public variation 2: Returns deleted revision only (wbyDeleted === true) + */ +export const GetLatestDeletedRevisionByEntryIdUseCase = + createAbstraction( + "GetLatestDeletedRevisionByEntryIdUseCase" + ); + +export namespace GetLatestDeletedRevisionByEntryIdUseCase { + export type Interface = IGetLatestRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +export namespace GetLatestNonDeletedRevisionByEntryIdUseCase { + export type Interface = IGetLatestRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * Public variation 3: Returns any latest revision (both deleted and non-deleted) + */ +export const GetLatestRevisionByEntryIdIncludingDeletedUseCase = + createAbstraction( + "GetLatestRevisionByEntryIdIncludingDeletedUseCase" + ); + +export namespace GetLatestRevisionByEntryIdIncludingDeletedUseCase { + export type Interface = IGetLatestRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * GetLatestRevisionByEntryIdRepository - Fetches latest revision from storage by entry ID. + */ +export interface IGetLatestRevisionByEntryIdRepository { + execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, RepositoryError>>; +} + +export interface IGetLatestRevisionByEntryIdRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = + IGetLatestRevisionByEntryIdRepositoryErrors[keyof IGetLatestRevisionByEntryIdRepositoryErrors]; + +export const GetLatestRevisionByEntryIdRepository = + createAbstraction( + "GetLatestRevisionByEntryIdRepository" + ); + +export namespace GetLatestRevisionByEntryIdRepository { + export type Interface = IGetLatestRevisionByEntryIdRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts new file mode 100644 index 00000000000..31915499b36 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts @@ -0,0 +1,27 @@ +import { createFeature } from "@webiny/feature/api"; +import { BaseUseCase } from "./BaseUseCase.js"; +import { GetLatestRevisionByEntryIdRepository } from "./GetLatestRevisionByEntryIdRepository.js"; +import { GetLatestRevisionByEntryIdUseCase } from "./variations/GetLatestRevisionByEntryIdUseCase.js"; +import { GetLatestDeletedRevisionByEntryIdUseCase } from "./variations/GetLatestDeletedRevisionByEntryIdUseCase.js"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "./variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.js"; + +export const GetLatestRevisionByEntryIdFeature = createFeature({ + name: "GetLatestRevisionByEntryId", + register(container) { + // Register repository (singleton scope) + container.register(GetLatestRevisionByEntryIdRepository).inSingletonScope(); + + // Register base use case (internal, returns any entry regardless of deleted state) + container.register(BaseUseCase); + + // Register three public use case variations (all use transient scope) + // 1. Non-deleted entry only (default) + container.register(GetLatestRevisionByEntryIdUseCase); + + // 2. Deleted entry only + container.register(GetLatestDeletedRevisionByEntryIdUseCase); + + // 3. Deleted AND non-deleted entry + container.register(GetLatestRevisionByEntryIdIncludingDeletedUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts new file mode 100644 index 00000000000..20384ea1511 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts @@ -0,0 +1,6 @@ +export { + GetLatestRevisionByEntryIdUseCase, + GetLatestDeletedRevisionByEntryIdUseCase, + GetLatestRevisionByEntryIdIncludingDeletedUseCase, + GetLatestRevisionByEntryIdRepository +} from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts new file mode 100644 index 00000000000..d4a36254bde --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestDeletedRevisionByEntryIdUseCase as UseCaseAbstraction } from "../abstractions.js"; +import { GetLatestRevisionByEntryIdBaseUseCase } from "../abstractions.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * Returns deleted entry only. + * + * Composes the base use case and filters to only deleted entries. + * Returns null if the entry doesn't exist or is not deleted (wbyDeleted !== true). + */ +class GetLatestDeletedRevisionByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private baseUseCase: GetLatestRevisionByEntryIdBaseUseCase.Interface) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, UseCaseAbstraction.Error>> { + const result = await this.baseUseCase.execute(model, params); + + if (result.isFail()) { + return result; + } + + const entry = result.value; + + // Return null if entry doesn't exist or is NOT deleted + if (!entry || !entry.wbyDeleted) { + return Result.fail(new EntryNotFoundError(params.id)); + } + + return Result.ok(entry); + } +} + +export const GetLatestDeletedRevisionByEntryIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetLatestDeletedRevisionByEntryIdUseCaseImpl, + dependencies: [GetLatestRevisionByEntryIdBaseUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts new file mode 100644 index 00000000000..14cb1ad76ac --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts @@ -0,0 +1,26 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase as UseCaseAbstraction } from "../abstractions.js"; +import { GetLatestRevisionByEntryIdBaseUseCase } from "../abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; + +/** + * Returns any latest revision (deled and non-deleted) + */ +class GetLatestRevisionByEntryIdIncludingDeletedUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private baseUseCase: GetLatestRevisionByEntryIdBaseUseCase.Interface) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise | null, UseCaseAbstraction.Error>> { + // Simply delegate to base use case without any filtering + return await this.baseUseCase.execute(model, params); + } +} + +export const GetLatestRevisionByEntryIdIncludingDeletedUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetLatestRevisionByEntryIdIncludingDeletedUseCaseImpl, + dependencies: [GetLatestRevisionByEntryIdBaseUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts new file mode 100644 index 00000000000..556fd855565 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts @@ -0,0 +1,45 @@ +import { createImplementation, Result } from "@webiny/feature/api"; +import { GetLatestRevisionByEntryIdUseCase as UseCaseAbstraction } from "../abstractions.js"; +import { GetLatestRevisionByEntryIdBaseUseCase } from "../abstractions.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * Returns non-deleted entry only. + * + * Composes the base use case and filters out deleted entries. + */ +class GetLatestRevisionByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private baseUseCase: GetLatestRevisionByEntryIdBaseUseCase.Interface) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetLatestRevisionParams + ): Promise, UseCaseAbstraction.Error>> { + const result = await this.baseUseCase.execute(model, params); + + if (result.isFail()) { + return result; + } + + const entry = result.value; + + // Return null if entry doesn't exist or is deleted + if (!entry || entry.wbyDeleted) { + return Result.fail(new EntryNotFoundError(params.id)); + } + + return Result.ok(entry); + } +} + +export const GetLatestRevisionByEntryIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetLatestRevisionByEntryIdUseCaseImpl, + dependencies: [GetLatestRevisionByEntryIdBaseUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts new file mode 100644 index 00000000000..27f727e9ab7 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPreviousRevisionByEntryIdBaseUseCase as BaseUseCaseAbstraction } from "./abstractions.js"; +import { GetPreviousRevisionByEntryIdRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetPreviousRevisionParams +} from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * Orchestrates fetching previous revision by entry ID and version. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + * - Returns entry regardless of deleted state (variations handle filtering) + */ +class GetPreviousRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction.Interface { + constructor( + private repository: GetPreviousRevisionByEntryIdRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetPreviousRevisionParams + ): Promise, BaseUseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, params); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const BaseUseCase = createImplementation({ + abstraction: BaseUseCaseAbstraction, + implementation: GetPreviousRevisionByEntryIdUseCaseImpl, + dependencies: [GetPreviousRevisionByEntryIdRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts new file mode 100644 index 00000000000..13002cb99b5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts @@ -0,0 +1,51 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPreviousRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetPreviousRevisionParams +} from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetPreviousRevisionByEntryIdRepository - Fetches previous revision by entry ID and version from storage. + * Returns the previous revision for a given entry (includes deleted entries). + */ +class GetPreviousRevisionByEntryIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetPreviousRevisionParams + ): Promise, RepositoryAbstraction.Error>> { + try { + const entry = await this.storageOperations.entries.getPreviousRevision(model, params); + + if (!entry) { + return Result.fail( + new EntryNotFoundError(`Previous revision not found for entry ${params.entryId}`) + ); + } + + // Transform storage entry to domain entry + const transformedEntry = await this.entryFromStorageTransform(model, entry); + + return Result.ok(transformedEntry as CmsEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetPreviousRevisionByEntryIdRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetPreviousRevisionByEntryIdRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts new file mode 100644 index 00000000000..76aac91d42c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts @@ -0,0 +1,46 @@ +import { createImplementation, Result } from "@webiny/feature/api"; +import { GetPreviousRevisionByEntryIdUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetPreviousRevisionByEntryIdBaseUseCase } from "./abstractions.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetPreviousRevisionParams +} from "~/types/index.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * Returns non-deleted previous revision only. + * + * Composes the base use case and filters out deleted entries. + */ +class GetPreviousRevisionByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private baseUseCase: GetPreviousRevisionByEntryIdBaseUseCase.Interface) {} + + async execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetPreviousRevisionParams + ): Promise, UseCaseAbstraction.Error>> { + const result = await this.baseUseCase.execute(model, params); + + if (result.isFail()) { + return result; + } + + const entry = result.value; + + // Return error if entry is deleted + if (entry.wbyDeleted) { + // TODO: should we be loading revisions till we find one that is not deleted? + return Result.fail(new EntryNotFoundError(params.entryId)); + } + + return Result.ok(entry); + } +} + +export const GetPreviousRevisionByEntryIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetPreviousRevisionByEntryIdUseCaseImpl, + dependencies: [GetPreviousRevisionByEntryIdBaseUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts new file mode 100644 index 00000000000..e7bb1008db4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts @@ -0,0 +1,80 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetPreviousRevisionParams +} from "~/types/index.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * Base internal use case - returns entry regardless of deleted state. + * This is used internally by the public variation. + */ +export interface IGetPreviousRevisionByEntryIdBaseUseCase { + execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetPreviousRevisionParams + ): Promise, UseCaseError>>; +} + +export interface IGetPreviousRevisionByEntryIdUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = + IGetPreviousRevisionByEntryIdUseCaseErrors[keyof IGetPreviousRevisionByEntryIdUseCaseErrors]; + +export const GetPreviousRevisionByEntryIdBaseUseCase = + createAbstraction( + "GetPreviousRevisionByEntryIdBaseUseCase" + ); + +export namespace GetPreviousRevisionByEntryIdBaseUseCase { + export type Interface = IGetPreviousRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * Public use case: Returns non-deleted revision only (default behavior) + */ +export const GetPreviousRevisionByEntryIdUseCase = + createAbstraction( + "GetPreviousRevisionByEntryIdUseCase" + ); + +export namespace GetPreviousRevisionByEntryIdUseCase { + export type Interface = IGetPreviousRevisionByEntryIdBaseUseCase; + export type Error = UseCaseError; +} + +/** + * GetPreviousRevisionByEntryIdRepository - Fetches previous revision from storage by entry ID and version. + */ +export interface IGetPreviousRevisionByEntryIdRepository { + execute( + model: CmsModel, + params: CmsEntryStorageOperationsGetPreviousRevisionParams + ): Promise, RepositoryError>>; +} + +export interface IGetPreviousRevisionByEntryIdRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = + IGetPreviousRevisionByEntryIdRepositoryErrors[keyof IGetPreviousRevisionByEntryIdRepositoryErrors]; + +export const GetPreviousRevisionByEntryIdRepository = + createAbstraction( + "GetPreviousRevisionByEntryIdRepository" + ); + +export namespace GetPreviousRevisionByEntryIdRepository { + export type Interface = IGetPreviousRevisionByEntryIdRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts new file mode 100644 index 00000000000..9c1456aae95 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts @@ -0,0 +1,18 @@ +import { createFeature } from "@webiny/feature/api"; +import { BaseUseCase } from "./BaseUseCase.js"; +import { GetPreviousRevisionByEntryIdRepository } from "./GetPreviousRevisionByEntryIdRepository.js"; +import { GetPreviousRevisionByEntryIdUseCase } from "./GetPreviousRevisionByEntryIdUseCase.js"; + +export const GetPreviousRevisionByEntryIdFeature = createFeature({ + name: "GetPreviousRevisionByEntryId", + register(container) { + // Register repository (singleton scope) + container.register(GetPreviousRevisionByEntryIdRepository).inSingletonScope(); + + // Register base use case (internal, returns any entry regardless of deleted state) + container.register(BaseUseCase); + + // Register public use case (non-deleted entries only) + container.register(GetPreviousRevisionByEntryIdUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts new file mode 100644 index 00000000000..d94f8ae7ebc --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts @@ -0,0 +1,4 @@ +export { + GetPreviousRevisionByEntryIdUseCase, + GetPreviousRevisionByEntryIdRepository +} from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts new file mode 100644 index 00000000000..6d5d32fa37e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPublishedEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetPublishedEntriesByIdsRepository - Fetches published entries by entry IDs from storage. + * Returns array of published entries. + */ +class GetPublishedEntriesByIdsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryAbstraction.Error>> { + try { + const result = await this.storageOperations.entries.getPublishedByIds(model, { ids }); + + // Transform storage entries to domain entries + const items = await Promise.all( + result.map(async entry => { + return this.entryFromStorageTransform(model, entry); + }) + ); + + return Result.ok(items as CmsEntry[]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetPublishedEntriesByIdsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetPublishedEntriesByIdsRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts new file mode 100644 index 00000000000..c8dafb68a0d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPublishedEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetPublishedEntriesByIdsRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetPublishedEntriesByIdsUseCase - Orchestrates fetching published entries by IDs. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + */ +class GetPublishedEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetPublishedEntriesByIdsRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, ids); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetPublishedEntriesByIdsUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetPublishedEntriesByIdsUseCaseImpl, + dependencies: [GetPublishedEntriesByIdsRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts new file mode 100644 index 00000000000..5d42b2c30e3 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetPublishedEntriesByIds Use Case - Fetches published revisions by entry IDs. + * Returns array of published entries (excludes deleted entries via decorator). + */ +export interface IGetPublishedEntriesByIdsUseCase { + execute( + model: CmsModel, + ids: string[] + ): Promise[], UseCaseError>>; +} + +export interface IGetPublishedEntriesByIdsUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IGetPublishedEntriesByIdsUseCaseErrors[keyof IGetPublishedEntriesByIdsUseCaseErrors]; + +export const GetPublishedEntriesByIdsUseCase = createAbstraction( + "GetPublishedEntriesByIdsUseCase" +); + +export namespace GetPublishedEntriesByIdsUseCase { + export type Interface = IGetPublishedEntriesByIdsUseCase; + export type Error = UseCaseError; +} + +/** + * GetPublishedEntriesByIdsRepository - Fetches published entries from storage by entry IDs. + */ +export interface IGetPublishedEntriesByIdsRepository { + execute( + model: CmsModel, + ids: string[] + ): Promise[], RepositoryError>>; +} + +export interface IGetPublishedEntriesByIdsRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IGetPublishedEntriesByIdsRepositoryErrors[keyof IGetPublishedEntriesByIdsRepositoryErrors]; + +export const GetPublishedEntriesByIdsRepository = createAbstraction( + "GetPublishedEntriesByIdsRepository" +); + +export namespace GetPublishedEntriesByIdsRepository { + export type Interface = IGetPublishedEntriesByIdsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts new file mode 100644 index 00000000000..af4036c4245 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts @@ -0,0 +1,38 @@ +import { createDecorator } from "@webiny/feature/api"; +import { GetPublishedEntriesByIdsUseCase } from "../abstractions.js"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; + +/** + * GetPublishedEntriesByIdsNotDeletedDecorator - Filters out deleted entries. + * + * This decorator wraps the GetPublishedEntriesByIdsUseCase and filters out + * entries marked as deleted (wbyDeleted flag). + */ +class GetPublishedEntriesByIdsNotDeletedDecoratorImpl implements GetPublishedEntriesByIdsUseCase.Interface { + constructor(private decoratee: GetPublishedEntriesByIdsUseCase.Interface) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], GetPublishedEntriesByIdsUseCase.Error>> { + const result = await this.decoratee.execute(model, ids); + + if (result.isFail()) { + return result; + } + + const entries = result.value; + + // Filter out deleted entries + const nonDeletedEntries = entries.filter(entry => !entry.wbyDeleted); + + return Result.ok(nonDeletedEntries); + } +} + +export const GetPublishedEntriesByIdsNotDeletedDecorator = createDecorator({ + abstraction: GetPublishedEntriesByIdsUseCase, + decorator: GetPublishedEntriesByIdsNotDeletedDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts new file mode 100644 index 00000000000..36dcc6f9b58 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts @@ -0,0 +1,13 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetPublishedEntriesByIdsUseCase } from "./GetPublishedEntriesByIdsUseCase.js"; +import { GetPublishedEntriesByIdsRepository } from "./GetPublishedEntriesByIdsRepository.js"; +import { GetPublishedEntriesByIdsNotDeletedDecorator } from "./decorators/GetPublishedEntriesByIdsNotDeletedDecorator.js"; + +export const GetPublishedEntriesByIdsFeature = createFeature({ + name: "GetPublishedEntriesByIds", + register(container) { + container.register(GetPublishedEntriesByIdsUseCase); + container.register(GetPublishedEntriesByIdsRepository).inSingletonScope(); + container.registerDecorator(GetPublishedEntriesByIdsNotDeletedDecorator); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts new file mode 100644 index 00000000000..2762f9abfba --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts @@ -0,0 +1 @@ +export { GetPublishedEntriesByIdsUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts index f8d68af5adc..1fef3cf4754 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts @@ -21,6 +21,6 @@ export const GetRevisionByIdFeature = createFeature({ container.register(GetRevisionByIdUseCase); // Register decorator (filters deleted entries) - container.register(GetRevisionByIdNotDeleted); + container.registerDecorator(GetRevisionByIdNotDeleted); } }); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts new file mode 100644 index 00000000000..d8b07d5acb7 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts @@ -0,0 +1,46 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetRevisionsByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetRevisionsByEntryIdRepository - Fetches all revisions for an entry from storage. + * Returns array of entry revisions. + */ +class GetRevisionsByEntryIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entryId: string + ): Promise[], RepositoryAbstraction.Error>> { + try { + const result = await this.storageOperations.entries.getRevisions(model, { + id: entryId + }); + + // Transform storage entries to domain entries + const items = await Promise.all( + result.map(async entry => { + return this.entryFromStorageTransform(model, entry); + }) + ); + + return Result.ok(items as CmsEntry[]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetRevisionsByEntryIdRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetRevisionsByEntryIdRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts new file mode 100644 index 00000000000..f2b47ff5c17 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetRevisionsByEntryIdUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetRevisionsByEntryIdRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetRevisionsByEntryIdUseCase - Orchestrates fetching all revisions for an entry. + * + * Responsibilities: + * - Apply access control + * - Delegate to repository for data fetching + */ +class GetRevisionsByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetRevisionsByEntryIdRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + model: CmsModel, + entryId: string + ): Promise[], UseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Delegate to repository + const result = await this.repository.execute(model, entryId); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetRevisionsByEntryIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetRevisionsByEntryIdUseCaseImpl, + dependencies: [GetRevisionsByEntryIdRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts new file mode 100644 index 00000000000..eb78e261891 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetRevisionsByEntryId Use Case - Fetches all revisions for a given entry ID. + * Returns array of entry revisions. + */ +export interface IGetRevisionsByEntryIdUseCase { + execute( + model: CmsModel, + entryId: string + ): Promise[], UseCaseError>>; +} + +export interface IGetRevisionsByEntryIdUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IGetRevisionsByEntryIdUseCaseErrors[keyof IGetRevisionsByEntryIdUseCaseErrors]; + +export const GetRevisionsByEntryIdUseCase = createAbstraction( + "GetRevisionsByEntryIdUseCase" +); + +export namespace GetRevisionsByEntryIdUseCase { + export type Interface = IGetRevisionsByEntryIdUseCase; + export type Error = UseCaseError; +} + +/** + * GetRevisionsByEntryIdRepository - Fetches entry revisions from storage. + */ +export interface IGetRevisionsByEntryIdRepository { + execute( + model: CmsModel, + entryId: string + ): Promise[], RepositoryError>>; +} + +export interface IGetRevisionsByEntryIdRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IGetRevisionsByEntryIdRepositoryErrors[keyof IGetRevisionsByEntryIdRepositoryErrors]; + +export const GetRevisionsByEntryIdRepository = createAbstraction( + "GetRevisionsByEntryIdRepository" +); + +export namespace GetRevisionsByEntryIdRepository { + export type Interface = IGetRevisionsByEntryIdRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts new file mode 100644 index 00000000000..e9390b90077 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetRevisionsByEntryIdUseCase } from "./GetRevisionsByEntryIdUseCase.js"; +import { GetRevisionsByEntryIdRepository } from "./GetRevisionsByEntryIdRepository.js"; + +export const GetRevisionsByEntryIdFeature = createFeature({ + name: "GetRevisionsByEntryId", + register(container) { + container.register(GetRevisionsByEntryIdUseCase); + container.register(GetRevisionsByEntryIdRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts new file mode 100644 index 00000000000..5823fa16d6b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts @@ -0,0 +1 @@ +export { GetRevisionsByEntryIdUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts new file mode 100644 index 00000000000..ea8911ed1ee --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts @@ -0,0 +1,42 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListDeletedEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListEntriesUseCase } from "./abstractions.js"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; + +/** + * ListDeletedEntriesUseCase - Lists deleted entries for manage API. + * Delegates to base ListEntriesUseCase with latest: true and wbyDeleted: true filters. + */ +class ListDeletedEntriesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private listEntriesUseCase: ListEntriesUseCase.Interface) {} + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseAbstraction.Error>> { + const { where, ...rest } = params || {}; + + // Add latest: true and wbyDeleted: true filters + return await this.listEntriesUseCase.execute(model, { + ...rest, + where: { + ...where, + latest: true, + wbyDeleted: true + } + }); + } +} + +export const ListDeletedEntriesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ListDeletedEntriesUseCaseImpl, + dependencies: [ListEntriesUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts new file mode 100644 index 00000000000..d8e9ab7e4ae --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts @@ -0,0 +1,66 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryStorageOperationsListParams, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform, SearchableFieldsProvider } from "~/legacy/abstractions.js"; + +/** + * ListEntriesRepository - Fetches entries from storage and transforms them. + * Returns entries with pagination metadata. + */ +class ListEntriesRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private searchableFieldsProvider: SearchableFieldsProvider.Interface, + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryListParams + ): Promise[], CmsEntryMeta], RepositoryAbstraction.Error>> { + try { + const limit = params.limit && params.limit > 0 ? params.limit : 50; + const listParams: CmsEntryStorageOperationsListParams = { + ...params, + limit, + where: { ...params.where }, + fields: this.searchableFieldsProvider({ fields: model.fields }) + }; + + const result = await this.storageOperations.entries.list(model, listParams); + + // Transform storage entries to domain entries + const items = await Promise.all( + result.items.map(async entry => { + return this.entryFromStorageTransform(model, entry); + }) + ); + + const meta: CmsEntryMeta = { + hasMoreItems: result.hasMoreItems, + totalCount: result.totalCount, + cursor: result.hasMoreItems ? result.cursor : null + }; + + return Result.ok([items as CmsEntry[], meta]); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const ListEntriesRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: ListEntriesRepositoryImpl, + dependencies: [SearchableFieldsProvider, EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts new file mode 100644 index 00000000000..f8d51ef2c82 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts @@ -0,0 +1,67 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { ListEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListEntriesRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * ListEntriesUseCase - Base use case for orchestrating entry listing with access control. + * + * Responsibilities: + * - Apply access control + * - Filter by owner if user can only access own entries + * - Delegate to repository for data fetching + */ +class ListEntriesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: ListEntriesRepository.Interface, + private accessControl: AccessControl.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseAbstraction.Error>> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + const { where: initialWhere, ...rest } = params || {}; + const where = { ...initialWhere }; + + // Possibly only get records which are owned by current user + if (await this.accessControl.canAccessOnlyOwnedEntries({ model })) { + where.createdBy = this.identityContext.getIdentity().id; + } + + // Delegate to repository + const result = await this.repository.execute(model, { + ...rest, + where + }); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const ListEntriesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ListEntriesUseCaseImpl, + dependencies: [ListEntriesRepository, AccessControl, IdentityContext] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts new file mode 100644 index 00000000000..8a0cc28409d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListLatestEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListEntriesUseCase } from "./abstractions.js"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; + +/** + * ListLatestEntriesUseCase - Lists latest entries for manage API. + * Delegates to base ListEntriesUseCase with latest: true filter. + */ +class ListLatestEntriesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private listEntriesUseCase: ListEntriesUseCase.Interface) {} + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseAbstraction.Error>> { + const { where, ...rest } = params || {}; + + // Add latest: true filter + return await this.listEntriesUseCase.execute(model, { + ...rest, + where: { + ...where, + latest: true + } + }); + } +} + +export const ListLatestEntriesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ListLatestEntriesUseCaseImpl, + dependencies: [ListEntriesUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts new file mode 100644 index 00000000000..9f86b301d6d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListPublishedEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListEntriesUseCase } from "./abstractions.js"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; + +/** + * ListPublishedEntriesUseCase - Lists published entries for read API. + * Delegates to base ListEntriesUseCase with published: true filter. + */ +class ListPublishedEntriesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private listEntriesUseCase: ListEntriesUseCase.Interface) {} + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseAbstraction.Error>> { + const { where, ...rest } = params || {}; + + // Add published: true filter + return await this.listEntriesUseCase.execute(model, { + ...rest, + where: { + ...where, + published: true + } + }); + } +} + +export const ListPublishedEntriesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ListPublishedEntriesUseCaseImpl, + dependencies: [ListEntriesUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts new file mode 100644 index 00000000000..9be0287a478 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts @@ -0,0 +1,118 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { + CmsEntry, + CmsEntryListParams, + CmsEntryMeta, + CmsEntryValues, + CmsModel +} from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * Base ListEntries Use Case - Internal base use case for listing entries. + * Used by specific variants (Latest, Published, Deleted). + */ +export interface IListEntriesUseCase { + execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseError>>; +} + +export interface IListEntriesUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IListEntriesUseCaseErrors[keyof IListEntriesUseCaseErrors]; + +export const ListEntriesUseCase = createAbstraction("ListEntriesUseCase"); + +export namespace ListEntriesUseCase { + export type Interface = IListEntriesUseCase; + export type Error = UseCaseError; +} + +/** + * ListLatestEntries Use Case - Lists latest entries (manage API). + */ +export interface IListLatestEntriesUseCase { + execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseError>>; +} + +export const ListLatestEntriesUseCase = createAbstraction( + "ListLatestEntriesUseCase" +); + +export namespace ListLatestEntriesUseCase { + export type Interface = IListLatestEntriesUseCase; + export type Error = UseCaseError; +} + +/** + * ListPublishedEntries Use Case - Lists published entries (read API). + */ +export interface IListPublishedEntriesUseCase { + execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseError>>; +} + +export const ListPublishedEntriesUseCase = createAbstraction( + "ListPublishedEntriesUseCase" +); + +export namespace ListPublishedEntriesUseCase { + export type Interface = IListPublishedEntriesUseCase; + export type Error = UseCaseError; +} + +/** + * ListDeletedEntries Use Case - Lists deleted entries (manage API). + */ +export interface IListDeletedEntriesUseCase { + execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], UseCaseError>>; +} + +export const ListDeletedEntriesUseCase = createAbstraction( + "ListDeletedEntriesUseCase" +); + +export namespace ListDeletedEntriesUseCase { + export type Interface = IListDeletedEntriesUseCase; + export type Error = UseCaseError; +} + +/** + * ListEntriesRepository - Fetches entries from storage with filtering and pagination. + */ +export interface IListEntriesRepository { + execute( + model: CmsModel, + params: CmsEntryListParams + ): Promise[], CmsEntryMeta], RepositoryError>>; +} + +export interface IListEntriesRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IListEntriesRepositoryErrors[keyof IListEntriesRepositoryErrors]; + +export const ListEntriesRepository = createAbstraction( + "ListEntriesRepository" +); + +export namespace ListEntriesRepository { + export type Interface = IListEntriesRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts new file mode 100644 index 00000000000..23605bde0b8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListEntriesUseCase } from "./ListEntriesUseCase.js"; +import { ListEntriesRepository } from "./ListEntriesRepository.js"; +import { ListLatestEntriesUseCase } from "./ListLatestEntriesUseCase.js"; +import { ListPublishedEntriesUseCase } from "./ListPublishedEntriesUseCase.js"; +import { ListDeletedEntriesUseCase } from "./ListDeletedEntriesUseCase.js"; + +export const ListEntriesFeature = createFeature({ + name: "ListEntries", + register(container) { + // Base use case and repository + container.register(ListEntriesUseCase); + container.register(ListEntriesRepository).inSingletonScope(); + + // Specific listing variants using composition + container.register(ListLatestEntriesUseCase); + container.register(ListPublishedEntriesUseCase); + container.register(ListDeletedEntriesUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts new file mode 100644 index 00000000000..8b6994a7db4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts @@ -0,0 +1,5 @@ +export { + ListLatestEntriesUseCase, + ListPublishedEntriesUseCase, + ListDeletedEntriesUseCase +} from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/legacy/abstractions.ts b/packages/api-headless-cms/src/legacy/abstractions.ts index ca40052e4ce..e11e7076388 100644 --- a/packages/api-headless-cms/src/legacy/abstractions.ts +++ b/packages/api-headless-cms/src/legacy/abstractions.ts @@ -1,6 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import type { PluginsContainer as PluginsContainerType } from "@webiny/plugins"; -import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { CmsEntry, CmsModel, CmsModelField } from "~/types/index.js"; export const PluginsContainer = createAbstraction("PluginsContainer"); @@ -8,6 +8,22 @@ export namespace PluginsContainer { export type Interface = PluginsContainerType; } +/** + * SearchableFieldsProvider - Provides searchable field paths for a model. + * Legacy abstraction for the getSearchableFields utility function. + */ +export interface ISearchableFieldsProvider { + (params: { fields: CmsModelField[] }): string[]; +} + +export const SearchableFieldsProvider = createAbstraction( + "SearchableFieldsProvider" +); + +export namespace SearchableFieldsProvider { + export type Interface = ISearchableFieldsProvider; +} + /** * EntryToStorageTransform - Transforms domain entry to storage format. * Legacy abstraction for the utility function. @@ -16,9 +32,8 @@ export interface IEntryToStorageTransform { (model: CmsModel, entry: CmsEntry): Promise; } -export const EntryToStorageTransform = createAbstraction( - "EntryToStorageTransform" -); +export const EntryToStorageTransform = + createAbstraction("EntryToStorageTransform"); export namespace EntryToStorageTransform { export type Interface = IEntryToStorageTransform; diff --git a/packages/api-headless-cms/src/types/context.ts b/packages/api-headless-cms/src/types/context.ts index 69b3f233a41..2e6ea127de6 100644 --- a/packages/api-headless-cms/src/types/context.ts +++ b/packages/api-headless-cms/src/types/context.ts @@ -68,7 +68,7 @@ export interface CmsEntryContext { /** * Get a single content entry for a model. */ - getEntry: ( + getEntry: ( model: CmsModel, params: CmsEntryGetParams ) => Promise>; @@ -79,7 +79,7 @@ export interface CmsEntryContext { /** * Get the entry for a model by a given ID. */ - getEntryById(model: CmsModel, revision: string): Promise>; + getEntryById(model: CmsModel, revision: string): Promise>; /** * List entries for a model. Internal method used by get, listLatest and listPublished. */ From c3716f1af6d551c3198751ebdc30ff776008a15e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 00:01:10 +0100 Subject: [PATCH 05/71] wip: migrate api-headless-cms --- packages/api-headless-cms-ddb-es/src/index.ts | 10 - .../entry/dataLoader/DataLoaderCache.ts | 4 +- .../entry/dataLoader/getAllEntryRevisions.ts | 3 +- .../dataLoader/getLatestRevisionByEntryId.ts | 3 +- .../getPublishedRevisionByEntryId.ts | 3 +- .../entry/dataLoader/getRevisionById.ts | 3 +- .../src/operations/entry/dataLoader/types.ts | 1 - .../src/operations/entry/dataLoaders.ts | 10 +- .../src/operations/entry/index.ts | 29 +- .../src/operations/entry/keys.ts | 5 +- .../src/operations/group/index.ts | 5 +- .../src/operations/model/index.ts | 5 +- .../src/operations/system/index.ts | 119 ----- packages/api-headless-cms-ddb-es/src/types.ts | 3 +- .../src/definitions/entry.ts | 3 - .../src/definitions/group.ts | 3 - .../src/definitions/model.ts | 4 - .../src/definitions/system.ts | 38 -- packages/api-headless-cms-ddb/src/index.ts | 10 - .../entry/dataLoader/DataLoaderCache.ts | 4 +- .../entry/dataLoader/getAllEntryRevisions.ts | 3 +- .../dataLoader/getLatestRevisionByEntryId.ts | 3 +- .../getPublishedRevisionByEntryId.ts | 3 +- .../entry/dataLoader/getRevisionById.ts | 3 +- .../src/operations/entry/dataLoader/types.ts | 1 - .../src/operations/entry/dataLoaders.ts | 6 +- .../src/operations/entry/index.ts | 13 - .../src/operations/entry/keys.ts | 10 +- .../src/operations/group/index.ts | 5 +- .../src/operations/model/index.ts | 7 +- .../src/operations/system/index.ts | 119 ----- packages/api-headless-cms-ddb/src/types.ts | 5 +- .../__tests__/contentAPI/aco/setup/helpers.ts | 31 -- .../__tests__/contentAPI/benchmark.test.ts | 8 - .../contentAPI/contentEntry.delete.test.ts | 30 +- .../contentEntry.deleteMultiple.test.ts | 4 +- .../contentAPI/contentEntry.restore.test.ts | 8 +- .../contentAPI/contentEntry.withId.test.ts | 4 +- .../contentAPI/contentEntryMetaField.test.ts | 1 - .../contentAPI/resolvers.read.test.ts | 6 +- .../__tests__/parameters/header.test.ts | 10 +- .../testHelpers/acceptIncommingChanges.ts | 4 +- .../__tests__/testHelpers/graphql/settings.ts | 12 +- packages/api-headless-cms/src/context.ts | 6 +- .../src/crud/contentEntry.crud.ts | 70 +-- .../src/domains/contentEntries/errors.ts | 11 +- .../contentEntries/ContentEntriesFeature.ts | 4 + .../CreateEntry/CreateEntryUseCase.ts | 4 +- .../DeleteEntry/DeleteEntryUseCase.ts | 2 +- .../DeleteEntry/MoveEntryToBinUseCase.ts | 2 +- .../decorators/ForceDeleteDecorator.ts | 2 + .../DeleteEntryRevisionUseCase.ts | 14 +- .../GetEntriesByIds/GetEntriesByIdsUseCase.ts | 2 +- .../GetEntry/GetEntryUseCase.ts | 2 +- .../GetLatestEntriesByIdsUseCase.ts | 2 +- .../GetLatestRevisionByEntryId/BaseUseCase.ts | 2 +- .../abstractions.ts | 1 + ...evisionByEntryIdIncludingDeletedUseCase.ts | 2 +- .../BaseUseCase.ts | 2 +- .../GetPreviousRevisionByEntryIdRepository.ts | 4 +- .../abstractions.ts | 1 + .../GetPublishedEntriesByIdsUseCase.ts | 2 +- .../GetRevisionsByEntryIdUseCase.ts | 2 +- .../ListEntries/ListEntriesUseCase.ts | 2 +- .../ListEntries/ListLatestEntriesUseCase.ts | 7 +- .../ListPublishedEntriesUseCase.ts | 3 +- .../RestoreEntryFromBinRepository.ts | 54 ++ .../RestoreEntryFromBinUseCase.ts | 151 ++++++ .../RestoreEntryFromBin/abstractions.ts | 77 +++ .../RestoreEntryFromBin/events.ts | 71 +++ .../RestoreEntryFromBin/feature.ts | 14 + .../RestoreEntryFromBin/index.ts | 2 + .../UpdateEntry/UpdateEntryUseCase.ts | 6 +- .../shared/EntriesRepository.ts | 489 ------------------ .../schema/resolvers/read/resolveGet.ts | 4 +- packages/api-headless-cms/src/utils/errors.ts | 9 + 76 files changed, 524 insertions(+), 1063 deletions(-) delete mode 100644 packages/api-headless-cms-ddb-es/src/operations/system/index.ts delete mode 100644 packages/api-headless-cms-ddb/src/definitions/system.ts delete mode 100644 packages/api-headless-cms-ddb/src/operations/system/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts delete mode 100644 packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts diff --git a/packages/api-headless-cms-ddb-es/src/index.ts b/packages/api-headless-cms-ddb-es/src/index.ts index 8e2730615a5..e16be830bd5 100644 --- a/packages/api-headless-cms-ddb-es/src/index.ts +++ b/packages/api-headless-cms-ddb-es/src/index.ts @@ -1,7 +1,6 @@ import dynamoDbValueFilters from "@webiny/db-dynamodb/plugins/filters/index.js"; import elasticsearchPlugins from "./elasticsearch/index.js"; import dynamoDbPlugins from "./dynamoDb/index.js"; -import { createSystemStorageOperations } from "./operations/system/index.js"; import { createModelsStorageOperations } from "./operations/model/index.js"; import { createEntriesStorageOperations } from "./operations/entry/index.js"; import type { StorageOperationsFactory } from "~/types.js"; @@ -12,7 +11,6 @@ import { createGroupEntity } from "~/definitions/group.js"; import { createModelEntity } from "~/definitions/model.js"; import { createEntryEntity } from "~/definitions/entry.js"; import { createEntryElasticsearchEntity } from "~/definitions/entryElasticsearch.js"; -import { createSystemEntity } from "~/definitions/system.js"; import { createElasticsearchIndex } from "~/elasticsearch/createElasticsearchIndex.js"; import { PluginsContainer } from "@webiny/plugins"; import { createGroupsStorageOperations } from "~/operations/group/index.js"; @@ -57,11 +55,6 @@ export const createStorageOperations: StorageOperationsFactory = params => { }); const entities = { - system: createSystemEntity({ - entityName: ENTITIES.SYSTEM, - table: tableInstance, - attributes: attributes ? attributes[ENTITIES.SYSTEM] : {} - }), groups: createGroupEntity({ entityName: ENTITIES.GROUPS, table: tableInstance, @@ -205,9 +198,6 @@ export const createStorageOperations: StorageOperationsFactory = params => { getEntities: () => entities, getTable: () => tableInstance, getEsTable: () => tableElasticsearchInstance, - system: createSystemStorageOperations({ - entity: entities.system - }), groups: createGroupsStorageOperations({ entity: entities.groups, plugins diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/DataLoaderCache.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/DataLoaderCache.ts index 88487d53c0c..704ed974604 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/DataLoaderCache.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/DataLoaderCache.ts @@ -3,12 +3,10 @@ import type DataLoader from "dataloader"; export interface CacheKeyParams { name: string; tenant: string; - locale: string; } export interface ClearAllParams { tenant: string; - locale: string; } export class DataLoaderCache { @@ -45,6 +43,6 @@ export class DataLoaderCache { } private createKey(params: CacheKeyParams): string { - return `${params.tenant}_${params.locale}_${params.name}`; + return `${params.tenant}_${params.name}`; } } diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getAllEntryRevisions.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getAllEntryRevisions.ts index 166bee0c3d0..dded23c96b8 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getAllEntryRevisions.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getAllEntryRevisions.ts @@ -8,7 +8,7 @@ import type { DataLoaderParams } from "./types.js"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetAllEntryRevisions = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; return new DataLoader( async (ids: readonly string[]) => { const results: Record = {}; @@ -17,7 +17,6 @@ export const createGetAllEntryRevisions = (params: DataLoaderParams) => { entity, partitionKey: createPartitionKey({ tenant, - locale, id }), options: { diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts index 703cd81adf8..197c835e3f4 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts @@ -7,7 +7,7 @@ import { createLatestSortKey, createPartitionKey } from "~/operations/entry/keys import type { DataLoaderParams } from "./types.js"; export const createGetLatestRevisionByEntryId = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; const latestKey = createLatestSortKey(); @@ -17,7 +17,6 @@ export const createGetLatestRevisionByEntryId = (params: DataLoaderParams) => { (collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); if (collection[partitionKey]) { diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts index 48b2bafcd84..e3bca713f40 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts @@ -7,7 +7,7 @@ import type { DataLoaderParams } from "./types.js"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetPublishedRevisionByEntryId = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; const publishedKey = createPublishedSortKey(); return new DataLoader( @@ -16,7 +16,6 @@ export const createGetPublishedRevisionByEntryId = (params: DataLoaderParams) => (collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); if (collection[partitionKey]) { diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts index ced626916dc..8bf004892a0 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts @@ -8,7 +8,7 @@ import { parseIdentifier } from "@webiny/utils"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetRevisionById = (params: DataLoaderParams) => { - const { entity, tenant, locale } = params; + const { entity, tenant } = params; return new DataLoader( async (ids: readonly string[]) => { @@ -16,7 +16,6 @@ export const createGetRevisionById = (params: DataLoaderParams) => { (collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); const { version } = parseIdentifier(id); diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/types.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/types.ts index 8950c0ca21c..262dac707ef 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/types.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/types.ts @@ -3,5 +3,4 @@ import type { Entity } from "@webiny/db-dynamodb/toolbox.js"; export interface DataLoaderParams { entity: Entity; tenant: string; - locale: string; } diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoaders.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoaders.ts index 15ed1af10d4..7b722ff0a85 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoaders.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoaders.ts @@ -13,12 +13,12 @@ import type { } from "~/types.js"; interface DataLoaderParams { - model: Pick; + model: Pick; ids: readonly string[]; } interface GetLoaderParams { - model: Pick; + model: Pick; } interface DataLoadersHandlerParams { @@ -26,7 +26,7 @@ interface DataLoadersHandlerParams { } export interface ClearAllParams { - model: Pick; + model: Pick; } export class DataLoadersHandler implements DataLoadersHandlerInterface { @@ -75,7 +75,6 @@ export class DataLoadersHandler implements DataLoadersHandlerInterface { const { model } = params; const cacheParams: CacheKeyParams = { tenant: model.tenant, - locale: model.locale, name }; let loader = this.cache.getDataLoader(cacheParams); @@ -85,8 +84,7 @@ export class DataLoadersHandler implements DataLoadersHandlerInterface { const factory = getDataLoaderFactory(name); loader = factory({ entity: this.entity, - tenant: model.tenant, - locale: model.locale + tenant: model.tenant }); this.cache.setDataLoader(cacheParams, loader); return loader; diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts index eafb528a465..8e7a34880d5 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts @@ -145,7 +145,6 @@ export const createEntriesStorageOperations = ( const revisionKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(entry) @@ -154,7 +153,6 @@ export const createEntriesStorageOperations = ( const latestKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createLatestSortKey() @@ -163,7 +161,6 @@ export const createEntriesStorageOperations = ( const publishedKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createPublishedSortKey() @@ -270,7 +267,6 @@ export const createEntriesStorageOperations = ( const revisionKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(entry) @@ -278,7 +274,6 @@ export const createEntriesStorageOperations = ( const latestKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createLatestSortKey() @@ -287,7 +282,6 @@ export const createEntriesStorageOperations = ( const publishedKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createPublishedSortKey() @@ -334,7 +328,6 @@ export const createEntriesStorageOperations = ( ...publishedRevisionStorageEntry, PK: createPartitionKey({ id: publishedRevisionStorageEntry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(publishedRevisionStorageEntry), @@ -423,7 +416,6 @@ export const createEntriesStorageOperations = ( const revisionKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(entry) @@ -431,7 +423,6 @@ export const createEntriesStorageOperations = ( const latestKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createLatestSortKey() @@ -440,7 +431,6 @@ export const createEntriesStorageOperations = ( const publishedKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createPublishedSortKey() @@ -538,7 +528,6 @@ export const createEntriesStorageOperations = ( ...updatedLatestStorageEntry, PK: createPartitionKey({ id: latestStorageEntry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(latestStorageEntry), @@ -624,7 +613,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }); /** @@ -771,7 +759,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -954,7 +941,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -1113,7 +1099,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }); @@ -1193,7 +1178,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -1263,7 +1247,6 @@ export const createEntriesStorageOperations = ( ...latestStorageEntry, PK: createPartitionKey({ id: initialLatestStorageEntry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(initialLatestStorageEntry), @@ -1352,7 +1335,6 @@ export const createEntriesStorageOperations = ( entityBatch.delete({ PK: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), SK: "L" @@ -1361,7 +1343,6 @@ export const createEntriesStorageOperations = ( elasticsearchEntityBatch.delete({ PK: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), SK: "L" @@ -1373,7 +1354,6 @@ export const createEntriesStorageOperations = ( entityBatch.delete({ PK: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), SK: "P" @@ -1382,7 +1362,6 @@ export const createEntriesStorageOperations = ( elasticsearchEntityBatch.delete({ PK: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), SK: "P" @@ -1395,7 +1374,6 @@ export const createEntriesStorageOperations = ( entityBatch.delete({ PK: createPartitionKey({ id: revision.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey({ @@ -1519,7 +1497,6 @@ export const createEntriesStorageOperations = ( const revisionKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey(entry) @@ -1527,7 +1504,6 @@ export const createEntriesStorageOperations = ( const latestKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createLatestSortKey() @@ -1535,7 +1511,6 @@ export const createEntriesStorageOperations = ( const publishedKeys = { PK: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), SK: createPublishedSortKey() @@ -1851,7 +1826,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -2076,13 +2050,12 @@ export const createEntriesStorageOperations = ( ) => { const model = getStorageOperationsModel(initialModel); - const { tenant, locale } = model; + const { tenant } = model; const { entryId, version } = params; const queryParams: QueryOneParams = { entity, partitionKey: createPartitionKey({ tenant, - locale, id: entryId }), options: { diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/keys.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/keys.ts index 696641c09f5..4ca3481b3e9 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/keys.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/keys.ts @@ -3,12 +3,11 @@ import { parseIdentifier, zeroPad } from "@webiny/utils"; export interface PartitionKeyParams { id: string; tenant: string; - locale: string; } export const createPartitionKey = (params: PartitionKeyParams): string => { - const { tenant, locale, id: initialId } = params; + const { tenant, id: initialId } = params; const { id } = parseIdentifier(initialId); - return `T#${tenant}#L#${locale}#CMS#CME#${id}`; + return `T#${tenant}#CMS#CME#${id}`; }; export interface SortKeyParams { diff --git a/packages/api-headless-cms-ddb-es/src/operations/group/index.ts b/packages/api-headless-cms-ddb-es/src/operations/group/index.ts index 957537d66fe..69b278c8a5e 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/group/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/group/index.ts @@ -20,11 +20,10 @@ import { deleteItem, put, cleanupItems } from "@webiny/db-dynamodb"; interface PartitionKeyParams { tenant: string; - locale: string; } const createPartitionKey = (params: PartitionKeyParams): string => { - const { tenant, locale } = params; - return `T#${tenant}#L#${locale}#CMS#CMG`; + const { tenant } = params; + return `T#${tenant}#CMS#CMG`; }; interface SortKeyParams { diff --git a/packages/api-headless-cms-ddb-es/src/operations/model/index.ts b/packages/api-headless-cms-ddb-es/src/operations/model/index.ts index ce046227477..491770806bc 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/model/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/model/index.ts @@ -18,12 +18,11 @@ import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; interface PartitionKeysParams { tenant: string; - locale: string; } const createPartitionKey = (params: PartitionKeysParams): string => { - const { tenant, locale } = params; - return `T#${tenant}#L#${locale}#CMS#CM`; + const { tenant } = params; + return `T#${tenant}#CMS#CM`; }; interface SortKeyParams { diff --git a/packages/api-headless-cms-ddb-es/src/operations/system/index.ts b/packages/api-headless-cms-ddb-es/src/operations/system/index.ts deleted file mode 100644 index d37c532f86e..00000000000 --- a/packages/api-headless-cms-ddb-es/src/operations/system/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { - CmsSystem, - CmsSystemStorageOperations, - CmsSystemStorageOperationsCreateParams, - CmsSystemStorageOperationsGetParams, - CmsSystemStorageOperationsUpdateParams -} from "@webiny/api-headless-cms/types/index.js"; -import type { Entity } from "@webiny/db-dynamodb/toolbox.js"; -import WebinyError from "@webiny/error"; -import { getClean } from "@webiny/db-dynamodb/utils/get.js"; -import { put } from "@webiny/db-dynamodb"; - -export interface CreateSystemStorageOperationsParams { - entity: Entity; -} - -interface PartitionKeyParams { - tenant: string; -} -const createPartitionKey = ({ tenant }: PartitionKeyParams): string => { - return `T#${tenant.toLowerCase()}#SYSTEM`; -}; -const createSortKey = (): string => { - return "CMS"; -}; - -interface Keys { - PK: string; - SK: string; -} -const createKeys = (params: PartitionKeyParams): Keys => { - return { - PK: createPartitionKey(params), - SK: createSortKey() - }; -}; - -export const createSystemStorageOperations = ( - params: CreateSystemStorageOperationsParams -): CmsSystemStorageOperations => { - const { entity } = params; - - const create = async ({ system }: CmsSystemStorageOperationsCreateParams) => { - const keys = createKeys(system); - try { - await put({ - entity, - item: { - ...system, - ...keys - } - }); - return system; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create system.", - ex.code || "CREATE_SYSTEM_ERROR", - { - error: ex, - system, - keys - } - ); - } - }; - - const update = async (params: CmsSystemStorageOperationsUpdateParams) => { - const { system } = params; - - const keys = createKeys(system); - - try { - await put({ - entity, - item: { - ...system, - ...keys - } - }); - return system; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update system.", - ex.code || "UPDATE_SYSTEM_ERROR", - { - error: ex, - system, - keys - } - ); - } - }; - - const get = async (params: CmsSystemStorageOperationsGetParams) => { - const keys = createKeys(params); - - try { - return await getClean({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not get system.", - ex.code || "GET_SYSTEM_ERROR", - { - error: ex, - keys - } - ); - } - }; - - return { - create, - update, - get - }; -}; diff --git a/packages/api-headless-cms-ddb-es/src/types.ts b/packages/api-headless-cms-ddb-es/src/types.ts index dfb95478ab9..815ee16218f 100644 --- a/packages/api-headless-cms-ddb-es/src/types.ts +++ b/packages/api-headless-cms-ddb-es/src/types.ts @@ -150,7 +150,6 @@ export interface CmsModelFieldToElasticsearchPlugin extends Plugin { export type Attributes = Record; export enum ENTITIES { - SYSTEM = "CmsSystem", GROUPS = "CmsGroups", MODELS = "CmsModels", ENTRIES = "CmsEntries", @@ -178,7 +177,7 @@ export interface HeadlessCmsStorageOperations extends BaseHeadlessCmsStorageOper getTable: () => Table; getEsTable: () => Table; getEntities: () => Record< - "system" | "groups" | "models" | "entries" | "entriesEs", + "groups" | "models" | "entries" | "entriesEs", Entity >; } diff --git a/packages/api-headless-cms-ddb/src/definitions/entry.ts b/packages/api-headless-cms-ddb/src/definitions/entry.ts index 5e60b62b5ff..64d821ceff7 100644 --- a/packages/api-headless-cms-ddb/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb/src/definitions/entry.ts @@ -49,9 +49,6 @@ export const createEntryEntity = (params: Params): Entity => { modelId: { type: "string" }, - locale: { - type: "string" - }, /** * Revision-level meta fields. 👇 diff --git a/packages/api-headless-cms-ddb/src/definitions/group.ts b/packages/api-headless-cms-ddb/src/definitions/group.ts index e56accf3014..09734d38aef 100644 --- a/packages/api-headless-cms-ddb/src/definitions/group.ts +++ b/packages/api-headless-cms-ddb/src/definitions/group.ts @@ -34,9 +34,6 @@ export const createGroupEntity = (params: Params): Entity => { slug: { type: "string" }, - locale: { - type: "string" - }, description: { type: "string" }, diff --git a/packages/api-headless-cms-ddb/src/definitions/model.ts b/packages/api-headless-cms-ddb/src/definitions/model.ts index e447a89b609..bef1f2db9d3 100644 --- a/packages/api-headless-cms-ddb/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb/src/definitions/model.ts @@ -44,10 +44,6 @@ export const createModelEntity = (params: Params): Entity => { type: "string", required: true }, - locale: { - type: "string", - required: true - }, group: { type: "map", required: true diff --git a/packages/api-headless-cms-ddb/src/definitions/system.ts b/packages/api-headless-cms-ddb/src/definitions/system.ts deleted file mode 100644 index c67acd77acb..00000000000 --- a/packages/api-headless-cms-ddb/src/definitions/system.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Table } from "@webiny/db-dynamodb/toolbox.js"; -import { Entity } from "@webiny/db-dynamodb/toolbox.js"; -import type { Attributes } from "~/types.js"; - -interface Params { - table: Table; - entityName: string; - attributes: Attributes; -} - -export const createSystemEntity = (params: Params): Entity => { - const { entityName, attributes, table } = params; - return new Entity({ - name: entityName, - table, - attributes: { - PK: { - partitionKey: true - }, - SK: { - sortKey: true - }, - version: { - type: "string" - }, - locale: { - type: "string" - }, - tenant: { - type: "string" - }, - readAPIKey: { - type: "string" - }, - ...(attributes || {}) - } - }); -}; diff --git a/packages/api-headless-cms-ddb/src/index.ts b/packages/api-headless-cms-ddb/src/index.ts index cc790f94b04..27befa5509d 100644 --- a/packages/api-headless-cms-ddb/src/index.ts +++ b/packages/api-headless-cms-ddb/src/index.ts @@ -3,12 +3,10 @@ import dynamoDbPlugins from "./dynamoDb/index.js"; import type { StorageOperationsFactory } from "~/types.js"; import { ENTITIES } from "~/types.js"; import { createTable } from "~/definitions/table.js"; -import { createSystemEntity } from "~/definitions/system.js"; import { createGroupEntity } from "~/definitions/group.js"; import { createModelEntity } from "~/definitions/model.js"; import { createEntryEntity } from "~/definitions/entry.js"; import { PluginsContainer } from "@webiny/plugins"; -import { createSystemStorageOperations } from "~/operations/system/index.js"; import { createGroupsStorageOperations } from "~/operations/group/index.js"; import { createModelsStorageOperations } from "~/operations/model/index.js"; import { createEntriesStorageOperations } from "./operations/entry/index.js"; @@ -35,11 +33,6 @@ export const createStorageOperations: StorageOperationsFactory = params => { }); const entities = { - system: createSystemEntity({ - entityName: ENTITIES.SYSTEM, - table: tableInstance, - attributes: attributes ? attributes[ENTITIES.SYSTEM] : {} - }), groups: createGroupEntity({ entityName: ENTITIES.GROUPS, table: tableInstance, @@ -110,9 +103,6 @@ export const createStorageOperations: StorageOperationsFactory = params => { }, getEntities: () => entities, getTable: () => tableInstance, - system: createSystemStorageOperations({ - entity: entities.system - }), groups: createGroupsStorageOperations({ entity: entities.groups, plugins diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/DataLoaderCache.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/DataLoaderCache.ts index 88487d53c0c..704ed974604 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/DataLoaderCache.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/DataLoaderCache.ts @@ -3,12 +3,10 @@ import type DataLoader from "dataloader"; export interface CacheKeyParams { name: string; tenant: string; - locale: string; } export interface ClearAllParams { tenant: string; - locale: string; } export class DataLoaderCache { @@ -45,6 +43,6 @@ export class DataLoaderCache { } private createKey(params: CacheKeyParams): string { - return `${params.tenant}_${params.locale}_${params.name}`; + return `${params.tenant}_${params.name}`; } } diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getAllEntryRevisions.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getAllEntryRevisions.ts index c3c0cda2932..4fc54d361cc 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getAllEntryRevisions.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getAllEntryRevisions.ts @@ -8,7 +8,7 @@ import type { DataLoaderParams } from "./types.js"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetAllEntryRevisions = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; return new DataLoader( async (ids: readonly string[]) => { const results: Record = {}; @@ -17,7 +17,6 @@ export const createGetAllEntryRevisions = (params: DataLoaderParams) => { entity, partitionKey: createPartitionKey({ tenant, - locale, id }), options: { diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts index e421ff02065..7871ce80121 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts @@ -8,7 +8,7 @@ import type { DataLoaderParams } from "./types.js"; import { parseIdentifier } from "@webiny/utils"; export const createGetLatestRevisionByEntryId = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; const latestKey = createLatestSortKey(); @@ -17,7 +17,6 @@ export const createGetLatestRevisionByEntryId = (params: DataLoaderParams) => { const queries = ids.reduce>((collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); if (collection[partitionKey]) { diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts index 3ae588d7748..c9469212e29 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts @@ -8,7 +8,7 @@ import { parseIdentifier } from "@webiny/utils"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetPublishedRevisionByEntryId = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; const publishedKey = createPublishedSortKey(); return new DataLoader( @@ -17,7 +17,6 @@ export const createGetPublishedRevisionByEntryId = (params: DataLoaderParams) => (collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); if (collection[partitionKey]) { diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts index 34cb96cd5b2..8bf004892a0 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts @@ -8,7 +8,7 @@ import { parseIdentifier } from "@webiny/utils"; import { createBatchScheduleFn } from "./createBatchScheduleFn.js"; export const createGetRevisionById = (params: DataLoaderParams) => { - const { entity, locale, tenant } = params; + const { entity, tenant } = params; return new DataLoader( async (ids: readonly string[]) => { @@ -16,7 +16,6 @@ export const createGetRevisionById = (params: DataLoaderParams) => { (collection, id) => { const partitionKey = createPartitionKey({ tenant, - locale, id }); const { version } = parseIdentifier(id); diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/types.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/types.ts index 8950c0ca21c..262dac707ef 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/types.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/types.ts @@ -3,5 +3,4 @@ import type { Entity } from "@webiny/db-dynamodb/toolbox.js"; export interface DataLoaderParams { entity: Entity; tenant: string; - locale: string; } diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts index d44714f6299..1f325e06d6c 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts @@ -13,12 +13,12 @@ import type { } from "~/types.js"; interface DataLoaderParams { - model: Pick; + model: Pick; ids: readonly string[]; } interface GetLoaderParams { - model: Pick; + model: Pick; } interface DataLoadersHandlerParams { @@ -71,7 +71,6 @@ export class DataLoadersHandler implements DataLoadersHandlerInterface { const { model } = params; const cacheParams: CacheKeyParams = { tenant: model.tenant, - locale: model.locale, name }; let loader = this.cache.getDataLoader(cacheParams); @@ -82,7 +81,6 @@ export class DataLoadersHandler implements DataLoadersHandlerInterface { loader = factory({ entity: this.entity, tenant: model.tenant, - locale: model.locale }); this.cache.setDataLoader(cacheParams, loader); return loader; diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts index 2372cfaffec..d5134a29fd4 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -151,7 +151,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -235,7 +234,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -334,7 +332,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -465,7 +462,6 @@ export const createEntriesStorageOperations = ( entity, partitionKey: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), options: { @@ -519,7 +515,6 @@ export const createEntriesStorageOperations = ( entity, partitionKey: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), options: { @@ -596,7 +591,6 @@ export const createEntriesStorageOperations = ( entity, partitionKey: createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }), options: { @@ -660,7 +654,6 @@ export const createEntriesStorageOperations = ( entity, partitionKey: createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }), options: { @@ -745,7 +738,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -835,7 +827,6 @@ export const createEntriesStorageOperations = ( for (const id of entries) { const partitionKey = createPartitionKey({ id, - locale: model.locale, tenant: model.tenant }); entityBatch.delete({ @@ -854,7 +845,6 @@ export const createEntriesStorageOperations = ( entityBatch.delete({ PK: createPartitionKey({ id: revision.id, - locale: model.locale, tenant: model.tenant }), SK: createRevisionSortKey({ @@ -1005,7 +995,6 @@ export const createEntriesStorageOperations = ( entity, partitionKey: createPartitionKey({ tenant: model.tenant, - locale: model.locale, id: entryId }), options: { @@ -1194,7 +1183,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); @@ -1366,7 +1354,6 @@ export const createEntriesStorageOperations = ( const partitionKey = createPartitionKey({ id: entry.id, - locale: model.locale, tenant: model.tenant }); diff --git a/packages/api-headless-cms-ddb/src/operations/entry/keys.ts b/packages/api-headless-cms-ddb/src/operations/entry/keys.ts index dd301a50cde..1fcf71c7bbf 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/keys.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/keys.ts @@ -3,16 +3,13 @@ import WebinyError from "@webiny/error"; interface BasePartitionKeyParams { tenant: string; - locale: string; } const createBasePartitionKey = (params: BasePartitionKeyParams): string => { - const { tenant, locale } = params; + const { tenant } = params; if (!tenant) { throw new WebinyError(`Missing tenant variable when creating entry basePartitionKey`); - } else if (!locale) { - throw new WebinyError(`Missing tenant variable when creating entry basePartitionKey`); } - return `T#${tenant}#L#${locale}#CMS#CME`; + return `T#${tenant}#CMS#CME`; }; export interface PartitionKeyParams extends BasePartitionKeyParams { @@ -21,7 +18,7 @@ export interface PartitionKeyParams extends BasePartitionKeyParams { export const createPartitionKey = (params: PartitionKeyParams): string => { const { id: initialId } = params; const { id } = parseIdentifier(initialId); - return `${createBasePartitionKey(params)}#CME#${id}`; + return `${createBasePartitionKey(params)}#${id}`; }; export interface SortKeyParams { @@ -41,7 +38,6 @@ export const createPublishedSortKey = (): string => { export interface GSIPartitionKeyParams { tenant: string; - locale: string; modelId: string; } export const createGSIPartitionKey = (params: GSIPartitionKeyParams, type: "A" | "L" | "P") => { diff --git a/packages/api-headless-cms-ddb/src/operations/group/index.ts b/packages/api-headless-cms-ddb/src/operations/group/index.ts index 322311e876a..a2e33d1411b 100644 --- a/packages/api-headless-cms-ddb/src/operations/group/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/group/index.ts @@ -20,11 +20,10 @@ import { deleteItem, put } from "@webiny/db-dynamodb"; interface PartitionKeyParams { tenant: string; - locale: string; } const createPartitionKey = (params: PartitionKeyParams): string => { - const { tenant, locale } = params; - return `T#${tenant}#L#${locale}#CMS#CMG`; + const { tenant } = params; + return `T#${tenant}#CMS#CMG`; }; interface SortKeyParams { diff --git a/packages/api-headless-cms-ddb/src/operations/model/index.ts b/packages/api-headless-cms-ddb/src/operations/model/index.ts index 1d2aff62485..22b7d41afe0 100644 --- a/packages/api-headless-cms-ddb/src/operations/model/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/model/index.ts @@ -17,16 +17,13 @@ import { deleteItem, put } from "@webiny/db-dynamodb"; interface PartitionKeysParams { tenant: string; - locale: string; } const createPartitionKey = (params: PartitionKeysParams): string => { - const { tenant, locale } = params; + const { tenant } = params; if (!tenant) { throw new WebinyError(`Missing tenant variable when creating model partitionKey.`); - } else if (!locale) { - throw new WebinyError(`Missing locale variable when creating model partitionKey.`); } - return `T#${tenant}#L#${locale}#CMS#CM`; + return `T#${tenant}#CMS#CM`; }; interface SortKeyParams { diff --git a/packages/api-headless-cms-ddb/src/operations/system/index.ts b/packages/api-headless-cms-ddb/src/operations/system/index.ts deleted file mode 100644 index 9456bd78d41..00000000000 --- a/packages/api-headless-cms-ddb/src/operations/system/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { - CmsSystem, - CmsSystemStorageOperations, - CmsSystemStorageOperationsCreateParams, - CmsSystemStorageOperationsGetParams, - CmsSystemStorageOperationsUpdateParams -} from "@webiny/api-headless-cms/types/index.js"; -import type { Entity } from "@webiny/db-dynamodb/toolbox.js"; -import WebinyError from "@webiny/error"; -import { getClean } from "@webiny/db-dynamodb/utils/get.js"; -import { put } from "@webiny/db-dynamodb"; - -interface CreateSystemStorageOperationsParams { - entity: Entity; -} - -interface PartitionKeyParams { - tenant: string; -} -const createPartitionKey = ({ tenant }: PartitionKeyParams): string => { - return `T#${tenant.toLowerCase()}#SYSTEM`; -}; -const createSortKey = (): string => { - return "CMS"; -}; - -interface Keys { - PK: string; - SK: string; -} -const createKeys = (params: PartitionKeyParams): Keys => { - return { - PK: createPartitionKey(params), - SK: createSortKey() - }; -}; - -export const createSystemStorageOperations = ( - params: CreateSystemStorageOperationsParams -): CmsSystemStorageOperations => { - const { entity } = params; - - const create = async ({ system }: CmsSystemStorageOperationsCreateParams) => { - const keys = createKeys(system); - try { - await put({ - entity, - item: { - ...system, - ...keys - } - }); - return system; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create system.", - ex.code || "CREATE_SYSTEM_ERROR", - { - error: ex, - system, - keys - } - ); - } - }; - - const update = async (params: CmsSystemStorageOperationsUpdateParams) => { - const { system } = params; - - const keys = createKeys(system); - - try { - await put({ - entity, - item: { - ...system, - ...keys - } - }); - return system; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update system.", - ex.code || "UPDATE_SYSTEM_ERROR", - { - error: ex, - system, - keys - } - ); - } - }; - - const get = async (params: CmsSystemStorageOperationsGetParams) => { - const keys = createKeys(params); - - try { - return await getClean({ - entity, - keys - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not get system.", - ex.code || "GET_SYSTEM_ERROR", - { - error: ex, - keys - } - ); - } - }; - - return { - create, - update, - get - }; -}; diff --git a/packages/api-headless-cms-ddb/src/types.ts b/packages/api-headless-cms-ddb/src/types.ts index c3f870a36bc..80af6aa7d02 100644 --- a/packages/api-headless-cms-ddb/src/types.ts +++ b/packages/api-headless-cms-ddb/src/types.ts @@ -37,7 +37,6 @@ export interface CmsFieldFilterValueTransformPlugin extends Plugin { export type Attributes = Record; export enum ENTITIES { - SYSTEM = "CmsSystem", GROUPS = "CmsGroups", MODELS = "CmsModels", ENTRIES = "CmsEntries" @@ -56,7 +55,7 @@ export interface StorageOperationsFactoryParams { export interface HeadlessCmsStorageOperations extends BaseHeadlessCmsStorageOperations { getTable: () => Table; - getEntities: () => Record<"system" | "groups" | "models" | "entries", Entity>; + getEntities: () => Record<"groups" | "models" | "entries", Entity>; } export interface StorageOperationsFactory { @@ -68,7 +67,7 @@ export interface CmsEntryStorageOperations extends BaseCmsEntryStorageOperations } export interface DataLoadersHandlerInterfaceClearAllParams { - model: Pick; + model: Pick; } export interface DataLoadersHandlerInterface { clearAll: (params?: DataLoadersHandlerInterfaceClearAllParams) => void; diff --git a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/helpers.ts b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/helpers.ts index 40d270b7c69..7b582355ba4 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/helpers.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/helpers.ts @@ -1,6 +1,4 @@ -import { ContextPlugin } from "@webiny/api"; import type { IdentityData } from "@webiny/api-core/features/IdentityContext"; -import type { CmsContext } from "~/types"; export interface PermissionsArg { name: string; @@ -49,10 +47,6 @@ export const createPermissions = (permissions?: PermissionsArg[]): PermissionsAr }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US", "de-DE"] } ]; }; @@ -63,28 +57,3 @@ export const createIdentity = (identity?: IdentityData) => { } return identity; }; - -export const createDummyLocales = () => { - return new ContextPlugin(async context => { - const { i18n, security } = context; - - await security.authenticate(""); - - await security.withoutAuthorization(async () => { - const [items] = await i18n.locales.listLocales({ - where: {} - }); - if (items.length > 0) { - return; - } - await i18n.locales.createLocale({ - code: "en-US", - default: true - }); - await i18n.locales.createLocale({ - code: "de-DE", - default: true - }); - }); - }); -}; diff --git a/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts b/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts index 87e80010737..490f959de7b 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts @@ -54,14 +54,6 @@ describe("benchmark points", () => { `Benchmark total time elapsed: ${elapsed}ms`, "Benchmark measurements:", [ - { - elapsed: expect.any(Number), - end: expect.any(Date), - memory: expect.any(Number), - name: "headlessCms.createContext", - category: "webiny", - start: expect.any(Date) - }, { elapsed: expect.any(Number), end: expect.any(Date), diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts index 7c186adafb8..d1c6a64ab92 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts @@ -132,7 +132,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -143,7 +143,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -161,7 +161,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -230,7 +230,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -264,7 +264,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -275,7 +275,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -296,7 +296,7 @@ describe("delete entries", () => { createCategoryFrom: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: expect.any(String) } @@ -315,7 +315,7 @@ describe("delete entries", () => { updateCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: expect.any(String) } @@ -331,7 +331,7 @@ describe("delete entries", () => { publishCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: expect.any(String) } @@ -347,7 +347,7 @@ describe("delete entries", () => { unpublishCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: expect.any(String) } @@ -364,7 +364,7 @@ describe("delete entries", () => { moveCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: expect.any(String) } @@ -386,7 +386,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -435,7 +435,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -446,7 +446,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -479,7 +479,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts index 34fdb959aec..c50027151ad 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts @@ -144,7 +144,7 @@ describe("delete multiple entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -177,7 +177,7 @@ describe("delete multiple entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts index b6b1dc4b090..f4cc909aca3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts @@ -135,7 +135,7 @@ describe("restore entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -146,7 +146,7 @@ describe("restore entries", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", message: expect.any(String) } } @@ -218,7 +218,7 @@ describe("restore entries", () => { restoreCategoryFromBin: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToRestore.entryId}" was not found!` } @@ -301,7 +301,7 @@ describe("restore entries", () => { restoreCategoryFromBin: { data: null, error: { - code: "NOT_FOUND", + code: "ENTRY_NOT_FOUND", data: null, message: `Entry "${categoryToRestore.entryId}" was not found!` } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts index 4d5b9f4f5a3..44e754ef114 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts @@ -187,7 +187,7 @@ describe("Content entry with user defined ID", () => { updateCategory: { data: null, error: { - code: "CONTENT_ENTRY_UPDATE_ERROR", + code: "CONTENT_ENTRY_LOCKED", message: "Cannot update entry because it's locked.", data: null } @@ -254,7 +254,7 @@ describe("Content entry with user defined ID", () => { updateCategory: { data: null, error: { - code: "CONTENT_ENTRY_UPDATE_ERROR", + code: "CONTENT_ENTRY_LOCKED", message: "Cannot update entry because it's locked.", data: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index f9e9d9e3c9a..2f103ac83b4 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -107,7 +107,6 @@ describe("Content Entry Meta Field", () => { displayName: "admin" }, modelId: model.modelId, - locale: model.locale, tenant: model.tenant, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts index 1e9e1800f0f..5b99c5fc0c9 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts @@ -199,7 +199,7 @@ describe("READ - Resolvers", () => { }); }); - it(`should return a NOT_FOUND error when getting an entry by non-existing ID`, async () => { + it(`should return a ENTRY_NOT_FOUND error when getting an entry by non-existing ID`, async () => { const { getCategory } = useCategoryReadHandler(readOpts); const [response] = await getCategory({ @@ -213,8 +213,8 @@ describe("READ - Resolvers", () => { getCategory: { data: null, error: { - code: "NOT_FOUND", - message: "Entry not found!", + code: "ENTRY_NOT_FOUND", + message: "Entry was not found!", data: null } } diff --git a/packages/api-headless-cms/__tests__/parameters/header.test.ts b/packages/api-headless-cms/__tests__/parameters/header.test.ts index 553f306be52..e15e9f90ddf 100644 --- a/packages/api-headless-cms/__tests__/parameters/header.test.ts +++ b/packages/api-headless-cms/__tests__/parameters/header.test.ts @@ -22,7 +22,7 @@ const correctTestCases: [ApiEndpoint][] = [["manage"], ["read"], ["preview"]]; describe("Header Parameter Plugin", () => { it.each(correctTestCases)( - "should properly extract type and locale from headers - %s, %s", + "should properly extract type from headers - %s", async type => { const plugin = createHeaderParameterPlugin(); @@ -52,8 +52,10 @@ describe("Header Parameter Plugin", () => { expect(isInstalledResponse).toEqual({ data: { - cms: { - version: null + system: { + isSystemInstalled: { + data: false + } } } }); @@ -88,7 +90,7 @@ describe("Header Parameter Plugin", () => { expect(isInstalledResponse).toMatchObject({ errors: [ { - message: `Cannot query field "cms" on type "Query".` + message: `Cannot query field "system" on type "Query".` } ] }); diff --git a/packages/api-headless-cms/__tests__/testHelpers/acceptIncommingChanges.ts b/packages/api-headless-cms/__tests__/testHelpers/acceptIncommingChanges.ts index 76a4a77dbc9..a64a8502de3 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/acceptIncommingChanges.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/acceptIncommingChanges.ts @@ -4,12 +4,12 @@ import type { CmsContext } from "~/types"; export const acceptIncomingChanges = () => { const plugin = new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` - extend type CmsMutation { + extend type Mutation { acceptIncomingChanges(modelId: String!, entryId: String!): CmsBooleanResponse } `, resolvers: { - CmsMutation: { + Mutation: { acceptIncomingChanges: async () => { return new Response(true); } diff --git a/packages/api-headless-cms/__tests__/testHelpers/graphql/settings.ts b/packages/api-headless-cms/__tests__/testHelpers/graphql/settings.ts index f460ac7869e..6ded063d925 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/graphql/settings.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/graphql/settings.ts @@ -1,15 +1,17 @@ export const IS_INSTALLED_QUERY = /* GraphQL */ ` - query IsCmsInstalled { - cms { - version + query IsInstalled { + system { + isSystemInstalled { + data + } } } `; export const INSTALL_MUTATION = /* GraphQL */ ` mutation CmsInstall { - cms { - install { + system { + installSystem(installationInput: []) { data error { message diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index eb5532f4e02..f3f63e7cc78 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -171,7 +171,11 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { return entryFromStorageTransform(context, model, entry); }); context.container.registerInstance(SearchableFieldsProvider, params => { - return getSearchableFields({ plugins: context.plugins, ...params }); + return getSearchableFields({ + plugins: context.plugins, + fields: params.fields, + input: [] + }); }); // Register features diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 791736c29a0..f446b935112 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -72,10 +72,7 @@ import { mapAndCleanUpdatedInputData } from "./contentEntry/entryDataFactories/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; -import { - getPublishedRevisionByEntryIdUseCases, - restoreEntryFromBinUseCases -} from "~/crud/contentEntry/useCases/index.js"; +import { getPublishedRevisionByEntryIdUseCases } from "~/crud/contentEntry/useCases/index.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; import type { GenericRecord } from "@webiny/api/types.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; @@ -98,6 +95,7 @@ import { GetRevisionsByEntryIdUseCase } from "~/features/contentEntries/GetRevis import { GetEntryUseCase } from "~/features/contentEntries/GetEntry/index.js"; import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntryRevision/index.js"; import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; +import { RestoreEntryFromBinUseCase } from "~/features/contentEntries/RestoreEntryFromBin/abstractions.js"; import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; interface CreateContentEntryCrudParams { @@ -278,23 +276,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm operation: storageOperations.entries.getPublishedRevisionByEntryId }); - /** - * Restore entry from bin - */ - const { restoreEntryFromBinUseCase } = restoreEntryFromBinUseCases({ - transform: transformEntryFromStorageCallable, - getEntry: getLatestRevisionByEntryIdDeletedUseCase, - getIdentity: getSecurityIdentity, - restoreOperation: storageOperations.entries.restoreFromBin, - topics: { - onEntryBeforeRestoreFromBin, - onEntryAfterRestoreFromBin, - onEntryRestoreFromBinError - }, - accessControl, - context - }); - const createEntry: CmsEntryContext["createEntry"] = async ( model: CmsModel, rawInput: CreateCmsEntryInput, @@ -318,11 +299,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm throw new WebinyError( error.message || "Could not create content entry.", error.code || "CREATE_ENTRY_ERROR", - { - error, - input: rawInput, - model - } + error.data ); } @@ -345,9 +322,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const originalResult = await useCase.execute(model, sourceId); if (originalResult.isFail()) { - throw new NotFoundError( - `Entry "${sourceId}" of model "${model.modelId}" was not found.` - ); + throw originalResult.error; } const originalEntry = originalResult.value; @@ -361,9 +336,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); if (latestStorageEntryResult.isFail()) { - throw new NotFoundError( - `Latest entry "${uniqueId}" of model "${model.modelId}" was not found.` - ); + throw latestStorageEntryResult.error; } const latestStorageEntry = latestStorageEntryResult.value; @@ -453,12 +426,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const error = result.error; throw new WebinyError( error.message || "Could not update existing entry.", - error.code || "UPDATE_ERROR", - { - error, - input: rawInput, - model - } + error.code || "UPDATE_ERROR" ); } @@ -478,7 +446,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const entryResult = await useCase.execute(model, id); if (entryResult.isFail()) { - throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); + throw entryResult.error; } originalEntry = entryResult.value; } @@ -504,7 +472,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const result = await useCase.execute(model, id); if (result.isFail()) { - throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); + throw result.error; } const entry = result.value; @@ -554,7 +522,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const useCase = context.container.resolve(GetRevisionByIdUseCase); const result = await useCase.execute(model, id); if (result.isFail()) { - throw new NotFoundError(`Entry "${id}" was not found!`); + throw result.error; } const originalEntry = result.value; @@ -722,7 +690,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const result = await useCase.execute(model, id); if (result.isFail()) { - throw new NotFoundError(`Entry "${id}" in the model "${model.modelId}" was not found.`); + throw result.error; } const originalEntry = result.value; @@ -738,7 +706,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); if (latestStorageEntryResult.isFail()) { - throw new NotFoundError(`Entry "${id}" in the model "${model.modelId}" was not found.`); + throw latestStorageEntryResult.error; } const latestStorageEntry = latestStorageEntryResult.value; @@ -806,7 +774,10 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); if (!originalStorageEntry) { - throw new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); + const error = new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); + // @ts-expect-error Temporary migration fix + error.code = "ENTRY_NOT_FOUND"; + throw error; } if (originalStorageEntry.id !== id) { @@ -1337,7 +1308,16 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return context.benchmark.measure( "headlessCms.crud.entries.restoreEntryFromBin", async () => { - return await restoreEntryFromBinUseCase.execute(model, entryId); + const useCase = context.container.resolve(RestoreEntryFromBinUseCase); + const result = await useCase.execute(model, entryId); + if (result.isFail()) { + throw new WebinyError( + result.error.message, + result.error.code, + result.error.data + ); + } + return result.value; } ); }, diff --git a/packages/api-headless-cms/src/domains/contentEntries/errors.ts b/packages/api-headless-cms/src/domains/contentEntries/errors.ts index 24ec1baac6a..89ce2f5c747 100644 --- a/packages/api-headless-cms/src/domains/contentEntries/errors.ts +++ b/packages/api-headless-cms/src/domains/contentEntries/errors.ts @@ -3,9 +3,9 @@ import { BaseError } from "@webiny/feature/api"; export class EntryNotFoundError extends BaseError { override readonly code = "ENTRY_NOT_FOUND" as const; - constructor(id: string) { + constructor(id?: string) { super({ - message: `Entry "${id}" was not found!` + message: id ? `Entry "${id}" was not found!` : `Entry was not found!` }); } } @@ -20,7 +20,6 @@ export class EntryNotAccessibleError extends BaseError { } } - export class EntryStorageError extends BaseError { override readonly code = "ENTRY_STORAGE_ERROR" as const; @@ -82,11 +81,11 @@ export class EntryNotInBinError extends BaseError { } export class EntryLockedError extends BaseError { - override readonly code = "ENTRY_LOCKED" as const; + override readonly code = "CONTENT_ENTRY_LOCKED" as const; - constructor(id: string) { + constructor() { super({ - message: `Cannot update entry "${id}" because it's locked.` + message: `Cannot update entry because it's locked.` }); } } diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts index 69ff47c471a..560994609fd 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -12,6 +12,8 @@ import { GetPreviousRevisionByEntryIdFeature } from "./GetPreviousRevisionByEntr import { GetEntryFeature } from "./GetEntry/feature.js"; import { DeleteEntryFeature } from "./DeleteEntry/feature.js"; import { DeleteEntryRevisionFeature } from "./DeleteEntryRevision/feature.js"; +import { RestoreEntryFromBinFeature } from "./RestoreEntryFromBin/feature.js"; +import { GetLatestRevisionByEntryIdFeature } from "./GetLatestRevisionByEntryId/feature.js"; export const ContentEntriesFeature = createFeature({ name: "ContentEntries", @@ -22,6 +24,7 @@ export const ContentEntriesFeature = createFeature({ GetEntryByIdFeature.register(container); GetPublishedEntriesByIdsFeature.register(container); GetLatestEntriesByIdsFeature.register(container); + GetLatestRevisionByEntryIdFeature.register(container); GetRevisionsByEntryIdFeature.register(container); GetPreviousRevisionByEntryIdFeature.register(container); GetEntryFeature.register(container); @@ -32,5 +35,6 @@ export const ContentEntriesFeature = createFeature({ UpdateEntryFeature.register(container); DeleteEntryFeature.register(container); DeleteEntryRevisionFeature.register(container); + RestoreEntryFromBinFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts index 89565715c79..e892cb28f9e 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts @@ -44,7 +44,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } try { @@ -67,7 +67,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromEntry(entry)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts index 9ff2f21eae6..dddffc23458 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts @@ -42,7 +42,7 @@ class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts index 111cd7917e1..9af92adcf0f 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts @@ -38,7 +38,7 @@ class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts index 319e7ef88ab..81a84b58d5c 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts @@ -39,6 +39,8 @@ class ForceDeleteDecoratorImpl implements DeleteEntryUseCase.Interface { entryId } } as any); + + return Result.ok(); } catch (error) { return Result.fail(new EntryStorageError(error as Error)); } diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index c8b3acc2cc9..fe76258089b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -48,7 +48,7 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } const { id: entryId, version } = parseIdentifier(revisionId); @@ -86,18 +86,14 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { entryId, version: version as number }); - if (previousRevisionResult.isFail()) { - return Result.fail(previousRevisionResult.error); - } - const previousRevision = previousRevisionResult.value; - - // If targeted record is the latest entry record and there is no previous one, - // we need to run full delete with hooks - if (entryToDelete.id === latestRevisionId && !previousRevision) { + // If targeted record is the latest entry record and there is no previous revision, + // delete the entire entry. + if (previousRevisionResult.isFail() && entryToDelete.id === latestRevisionId) { return await this.deleteEntry.execute(model, revisionId, {}); } + const previousRevision = previousRevisionResult.value; // Determine the entry to set as latest (if deleting current latest) let latestEntry = null; if (entryToDelete.id === latestRevisionId && previousRevision) { diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts index 0a5ff2b0444..2d195ef2240 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts @@ -26,7 +26,7 @@ class GetEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts index 7f1be8a3310..d34c24bf83f 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts @@ -31,7 +31,7 @@ class GetEntryUseCaseImpl implements UseCaseAbstraction.Interface { const entry = entries[0]; if (!entry) { - return Result.fail(new EntryNotFoundError("Entry not found!")); + return Result.fail(new EntryNotFoundError()); } return Result.ok(entry); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts index d22d464f98e..06ba3e9afae 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts @@ -26,7 +26,7 @@ class GetLatestEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts index 1705de7b0f1..d6b3634658b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts @@ -32,7 +32,7 @@ class GetLatestRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction.In // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts index bebfa42a3f4..7248d125760 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts @@ -93,6 +93,7 @@ export interface IGetLatestRevisionByEntryIdRepository { export interface IGetLatestRevisionByEntryIdRepositoryErrors { storage: EntryStorageError; + notFound: EntryNotFoundError; } type RepositoryError = diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts index 14cb1ad76ac..2d9ec017efb 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts @@ -13,7 +13,7 @@ class GetLatestRevisionByEntryIdIncludingDeletedUseCaseImpl implements UseCaseAb async execute( model: CmsModel, params: CmsEntryStorageOperationsGetLatestRevisionParams - ): Promise | null, UseCaseAbstraction.Error>> { + ): Promise, UseCaseAbstraction.Error>> { // Simply delegate to base use case without any filtering return await this.baseUseCase.execute(model, params); } diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts index 27f727e9ab7..c8a1e17bc03 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts @@ -32,7 +32,7 @@ class GetPreviousRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction. // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts index 13002cb99b5..4d93324a6be 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts @@ -29,9 +29,7 @@ class GetPreviousRevisionByEntryIdRepositoryImpl implements RepositoryAbstractio const entry = await this.storageOperations.entries.getPreviousRevision(model, params); if (!entry) { - return Result.fail( - new EntryNotFoundError(`Previous revision not found for entry ${params.entryId}`) - ); + return Result.fail(new EntryNotFoundError(params.entryId)); } // Transform storage entry to domain entry diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts index e7bb1008db4..740e4071a0b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts @@ -64,6 +64,7 @@ export interface IGetPreviousRevisionByEntryIdRepository { export interface IGetPreviousRevisionByEntryIdRepositoryErrors { storage: EntryStorageError; + notFound: EntryNotFoundError; } type RepositoryError = diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts index c8dafb68a0d..48bf1b6693a 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts @@ -26,7 +26,7 @@ class GetPublishedEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interfac // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts index f2b47ff5c17..12f66493b43 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts @@ -26,7 +26,7 @@ class GetRevisionsByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts index f8d51ef2c82..a9df2e0a0a5 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts @@ -35,7 +35,7 @@ class ListEntriesUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } const { where: initialWhere, ...rest } = params || {}; diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts index 8a0cc28409d..bb3fb498166 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts @@ -11,8 +11,7 @@ import type { } from "~/types/index.js"; /** - * ListLatestEntriesUseCase - Lists latest entries for manage API. - * Delegates to base ListEntriesUseCase with latest: true filter. + * Lists latest entries for manage API (non-deleted). */ class ListLatestEntriesUseCaseImpl implements UseCaseAbstraction.Interface { constructor(private listEntriesUseCase: ListEntriesUseCase.Interface) {} @@ -23,12 +22,12 @@ class ListLatestEntriesUseCaseImpl implements UseCaseAbstraction.Interface { ): Promise[], CmsEntryMeta], UseCaseAbstraction.Error>> { const { where, ...rest } = params || {}; - // Add latest: true filter return await this.listEntriesUseCase.execute(model, { ...rest, where: { ...where, - latest: true + latest: true, + wbyDeleted_not: true } }); } diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts index 9f86b301d6d..b897e50f85a 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts @@ -28,7 +28,8 @@ class ListPublishedEntriesUseCaseImpl implements UseCaseAbstraction.Interface { ...rest, where: { ...where, - published: true + published: true, + wbyDeleted_not: true } }); } diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts new file mode 100644 index 00000000000..c89711a0164 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts @@ -0,0 +1,54 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { RestoreEntryFromBinRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * RestoreEntryFromBinRepository - Handles storage operations for restoring entries from bin. + * + * Responsibilities: + * - Transform entry to storage format + * - Call storage operation to restore entry + * - Transform result back from storage format + * - Handle storage errors + */ +class RestoreEntryFromBinRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Call storage operation + const result = await this.storageOperations.entries.restoreFromBin(model, { + entry, + storageEntry + }); + + // Transform result back from storage format + const transformedEntry = await this.entryFromStorageTransform(model, result); + + return Result.ok(transformedEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const RestoreEntryFromBinRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: RestoreEntryFromBinRepositoryImpl, + dependencies: [EntryToStorageTransform, EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts new file mode 100644 index 00000000000..3b5810915d1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts @@ -0,0 +1,151 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { RestoreEntryFromBinUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { RestoreEntryFromBinRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetLatestDeletedRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { + EntryBeforeRestoreFromBinEvent, + EntryAfterRestoreFromBinEvent, + EntryRestoreFromBinErrorEvent +} from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { getDate } from "~/utils/date.js"; +import { getIdentity } from "~/utils/identity.js"; + +/** + * RestoreEntryFromBinUseCase - Orchestrates restoring a soft-deleted entry from the bin. + * + * Responsibilities: + * - Apply access control + * - Get the deleted entry to restore by ID + * - Clear deletion flags (wbyDeleted = false) + * - Restore entry to its original folder + * - Update restoration metadata + * - Publish domain events + * - Delegate to repository for storage operations + */ +class RestoreEntryFromBinUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: RestoreEntryFromBinRepository.Interface, + private accessControl: AccessControl.Interface, + private getDeletedEntry: GetLatestDeletedRevisionByEntryIdUseCase.Interface, + private identityContext: IdentityContext.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the deleted entry to restore by ID + const getResult = await this.getDeletedEntry.execute(model, { id }); + + if (getResult.isFail()) { + return Result.fail(new EntryNotFoundError(id)); + } + + const originalEntry = getResult.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Create the restored entry data + const currentDateTime = new Date().toISOString(); + const currentIdentity = this.identityContext.getIdentity(); + + const entryToRestore: CmsEntry = { + ...originalEntry, + wbyDeleted: false, + + // Entry location fields - restore to original folder + location: { + folderId: originalEntry.binOriginalFolderId + }, + binOriginalFolderId: null, + + // Entry-level meta fields + restoredOn: getDate(currentDateTime, null), + restoredBy: getIdentity(currentIdentity, null), + + // Revision-level meta fields + revisionRestoredOn: getDate(currentDateTime, null), + revisionRestoredBy: getIdentity(currentIdentity, null) + }; + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeRestoreFromBinEvent({ + entry: entryToRestore, + model + }) + ); + + // Delegate to repository + const result = await this.repository.execute(model, entryToRestore); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryRestoreFromBinErrorEvent({ + entry: entryToRestore, + model, + error: result.error + }) + ); + return Result.fail(result.error); + } + + const restoredEntry = result.value; + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterRestoreFromBinEvent({ + entry: restoredEntry, + model + }) + ); + + return Result.ok(restoredEntry); + } catch (error) { + await this.eventPublisher.publish( + new EntryRestoreFromBinErrorEvent({ + entry: entryToRestore, + model, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const RestoreEntryFromBinUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: RestoreEntryFromBinUseCaseImpl, + dependencies: [ + RestoreEntryFromBinRepository, + AccessControl, + GetLatestDeletedRevisionByEntryIdUseCase, + IdentityContext, + EventPublisher + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts new file mode 100644 index 00000000000..34b0d875cf6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts @@ -0,0 +1,77 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * RestoreEntryFromBin Use Case - Restores a soft-deleted entry from the bin. + * This clears the wbyDeleted flag and restores the entry to its original folder. + */ +export interface IRestoreEntryFromBinUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IRestoreEntryFromBinUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IRestoreEntryFromBinUseCaseErrors[keyof IRestoreEntryFromBinUseCaseErrors]; + +export const RestoreEntryFromBinUseCase = createAbstraction( + "RestoreEntryFromBinUseCase" +); + +export namespace RestoreEntryFromBinUseCase { + export type Interface = IRestoreEntryFromBinUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before restore event + */ +export interface EntryBeforeRestoreFromBinPayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for after restore event + */ +export interface EntryAfterRestoreFromBinPayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for restore error event + */ +export interface EntryRestoreFromBinErrorPayload { + entry: CmsEntry; + model: CmsModel; + error: Error; +} + +/** + * RestoreEntryFromBinRepository - Handles storage operations for restoring entries from bin. + */ +export interface IRestoreEntryFromBinRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IRestoreEntryFromBinRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IRestoreEntryFromBinRepositoryErrors[keyof IRestoreEntryFromBinRepositoryErrors]; + +export const RestoreEntryFromBinRepository = createAbstraction( + "RestoreEntryFromBinRepository" +); + +export namespace RestoreEntryFromBinRepository { + export type Interface = IRestoreEntryFromBinRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts new file mode 100644 index 00000000000..e9b73a89e8e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts @@ -0,0 +1,71 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforeRestoreFromBinPayload, + EntryAfterRestoreFromBinPayload, + EntryRestoreFromBinErrorPayload +} from "./abstractions.js"; + +/** + * Before restore entry from bin event + */ +export class EntryBeforeRestoreFromBinEvent extends DomainEvent { + eventType = "entry.beforeRestoreFromBin" as const; + + getHandlerAbstraction() { + return EntryBeforeRestoreFromBinHandler; + } +} + +export const EntryBeforeRestoreFromBinHandler = + createAbstraction>( + "EntryBeforeRestoreFromBinHandler" + ); + +export namespace EntryBeforeRestoreFromBinHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeRestoreFromBinEvent; +} + +/** + * After restore entry from bin event + */ +export class EntryAfterRestoreFromBinEvent extends DomainEvent { + eventType = "entry.afterRestoreFromBin" as const; + + getHandlerAbstraction() { + return EntryAfterRestoreFromBinHandler; + } +} + +export const EntryAfterRestoreFromBinHandler = + createAbstraction>( + "EntryAfterRestoreFromBinHandler" + ); + +export namespace EntryAfterRestoreFromBinHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterRestoreFromBinEvent; +} + +/** + * Restore entry from bin error event + */ +export class EntryRestoreFromBinErrorEvent extends DomainEvent { + eventType = "entry.restoreFromBinError" as const; + + getHandlerAbstraction() { + return EntryRestoreFromBinErrorHandler; + } +} + +export const EntryRestoreFromBinErrorHandler = + createAbstraction>( + "EntryRestoreFromBinErrorHandler" + ); + +export namespace EntryRestoreFromBinErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryRestoreFromBinErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts new file mode 100644 index 00000000000..f6a2ccd10ed --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { RestoreEntryFromBinUseCase } from "./RestoreEntryFromBinUseCase.js"; +import { RestoreEntryFromBinRepository } from "./RestoreEntryFromBinRepository.js"; + +export const RestoreEntryFromBinFeature = createFeature({ + name: "RestoreEntryFromBin", + register(container) { + // Register repository (singleton scope) + container.register(RestoreEntryFromBinRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(RestoreEntryFromBinUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts index a3c03035d37..3bce150b631 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts @@ -52,7 +52,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } try { @@ -66,7 +66,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check if entry is locked if (originalEntry.locked) { - return Result.fail(new EntryLockedError(id)); + return Result.fail(new EntryLockedError()); } // Transform raw input to updated domain entry @@ -89,7 +89,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(NotAuthorizedError.fromModel(model)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts deleted file mode 100644 index 710deea57a0..00000000000 --- a/packages/api-headless-cms/src/features/contentEntries/shared/EntriesRepository.ts +++ /dev/null @@ -1,489 +0,0 @@ -// @ts-nocheck -import { Result } from "@webiny/feature/api"; -import { createImplementation } from "@webiny/feature/api"; -import { EntriesRepository as RepositoryAbstraction } from "./abstractions.js"; -import { - EntryNotFoundError, - EntryStorageError, - EntryAlreadyPublishedError, - EntryNotPublishedError, - EntryInBinError, - EntryNotInBinError -} from "~/domains/contentEntries/errors.js"; -import type { CmsEntry, CmsEntryMeta, CmsEntryListParams, CmsModel } from "~/types/index.js"; -import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; -import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; -import { PluginsContainer } from "~/legacy/abstractions.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; - -/** - * EntriesRepository implementation following CQS principle. - * Provides access to database-stored entries with access control. - * Note: Entries are only stored in database, no plugin entries exist. - */ -class EntriesRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private pluginsContainer: PluginsContainer.Interface, - private storageOperations: StorageOperations.Interface, - private accessControl: AccessControl.Interface - ) {} - - async getById( - model: CmsModel, - id: string - ): Promise> { - try { - const [storageEntry] = await this.storageOperations.entries.getByIds(model, { - ids: [id] - }); - - if (!storageEntry) { - return Result.fail(new EntryNotFoundError(id)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - if (!canAccess) { - return Result.fail(new EntryNotFoundError(id)); - } - - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - return Result.ok(entry); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async getLatestRevision( - model: CmsModel, - entryId: string - ): Promise> { - try { - const storageEntry = await this.storageOperations.entries.getLatestRevisionByEntryId( - model, - { - id: entryId - } - ); - - if (!storageEntry) { - return Result.fail(new EntryNotFoundError(entryId)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - - if (!canAccess) { - return Result.fail(new NotAuthorizedError()); - } - - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - return Result.ok(entry); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async getPublishedRevision( - model: CmsModel, - entryId: string - ): Promise> { - try { - const storageEntry = await this.storageOperations.entries.getPublishedRevisionByEntryId( - model, - { - id: entryId - } - ); - - if (!storageEntry) { - return Result.fail(new EntryNotFoundError(entryId)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - - if (!canAccess) { - return Result.fail(new NotAuthorizedError()); - } - - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - return Result.ok(entry); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async getPreviousRevision( - model: CmsModel, - entryId: string, - version: number - ): Promise> { - try { - const storageEntry = await this.storageOperations.entries.getPreviousRevision(model, { - entryId, - version - }); - - if (!storageEntry) { - return Result.fail(new EntryNotFoundError(entryId)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - - if (!canAccess) { - return Result.fail(new NotAuthorizedError()); - } - - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - return Result.ok(entry); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async list( - model: CmsModel, - params: CmsEntryListParams - ): Promise> { - try { - const { where: initialWhere, limit: initialLimit } = params || {}; - const limit = initialLimit && initialLimit > 0 ? initialLimit : 50; - const where = { ...initialWhere }; - const listParams = { ...params, where, limit }; - - const { hasMoreItems, totalCount, cursor, items } = - await this.storageOperations.entries.list(model, listParams); - - // Apply access control to all entries - const accessibleEntries: CmsEntry[] = []; - for (const storageEntry of items) { - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - if (canAccess) { - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - accessibleEntries.push(entry); - } - } - - return Result.ok([accessibleEntries, { hasMoreItems, totalCount, cursor }]); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async getRevisions( - model: CmsModel, - entryId: string - ): Promise> { - try { - const revisions = await this.storageOperations.entries.getRevisions(model, { - id: entryId - }); - - // Apply access control to all revisions - const accessibleRevisions: CmsEntry[] = []; - for (const storageEntry of revisions) { - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - - if (canAccess) { - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - accessibleRevisions.push(entry); - } - } - - return Result.ok(accessibleRevisions); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async getByIds( - model: CmsModel, - ids: string[] - ): Promise> { - try { - const entries = await this.storageOperations.entries.getByIds(model, { ids }); - - // Apply access control to all entries - const accessibleEntries: CmsEntry[] = []; - for (const storageEntry of entries) { - const canAccess = await this.accessControl.canAccessEntry({ - model, - entry: storageEntry - }); - if (canAccess) { - const entry = await entryFromStorageTransform( - { plugins: this.pluginsContainer }, - model, - storageEntry - ); - - accessibleEntries.push(entry); - } - } - - return Result.ok(accessibleEntries); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async create( - model: CmsModel, - entry: CmsEntry - ): Promise> { - try { - const storageEntry = await entryToStorageTransform( - { plugins: this.pluginsContainer }, - model, - entry - ); - - await this.storageOperations.entries.create(model, { entry, storageEntry }); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async createRevisionFrom( - model: CmsModel, - entry: CmsEntry - ): Promise> { - try { - const storageEntry = await entryToStorageTransform( - { plugins: this.pluginsContainer }, - model, - entry - ); - await this.storageOperations.entries.createRevisionFrom(model, { entry, storageEntry }); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async update( - model: CmsModel, - entry: CmsEntry - ): Promise> { - try { - // Verify entry exists - const existingEntry = await this.storageOperations.entries.getRevisionById(model, { - id: entry.id - }); - - if (!existingEntry) { - return Result.fail(new EntryNotFoundError(entry.id)); - } - - const storageEntry = await entryToStorageTransform( - { plugins: this.pluginsContainer }, - model, - entry - ); - - await this.storageOperations.entries.update(model, { entry, storageEntry }); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async delete(model: CmsModel, id: string): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - await this.storageOperations.entries.delete(model, id); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async deleteRevision( - model: CmsModel, - id: string - ): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - await this.storageOperations.entries.deleteRevision(model, id); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async publish(model: CmsModel, id: string): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.getRevisionById(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - // Check if already published - if (entry.status === "published") { - return Result.fail(new EntryAlreadyPublishedError(id)); - } - - await this.storageOperations.entries.publish(model, { entry }); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async unpublish( - model: CmsModel, - id: string - ): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - // Check if not published - if (entry.status !== "published") { - return Result.fail(new EntryNotPublishedError(id)); - } - - await this.storageOperations.entries.unpublish(model, id); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async move( - model: CmsModel, - id: string, - folderId: string - ): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - await this.storageOperations.entries.move(model, id, folderId); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async moveToBin( - model: CmsModel, - id: string - ): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - // Check if already in bin - if (entry.wbyAco_location?.folderId === "bin") { - return Result.fail(new EntryInBinError(id)); - } - - await this.storageOperations.entries.moveToBin(model, id); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } - - async restoreFromBin( - model: CmsModel, - id: string - ): Promise> { - try { - // Verify entry exists - const entry = await this.storageOperations.entries.get(model, { id }); - if (!entry) { - return Result.fail(new EntryNotFoundError(id)); - } - - // Check if not in bin - if (entry.wbyAco_location?.folderId !== "bin") { - return Result.fail(new EntryNotInBinError(id)); - } - - await this.storageOperations.entries.restoreFromBin(model, id); - return Result.ok(); - } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); - } - } -} - -export const EntriesRepository = createImplementation({ - abstraction: RepositoryAbstraction, - implementation: EntriesRepositoryImpl, - dependencies: [PluginsContainer, StorageOperations, AccessControl] -}); diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts index 30aca41eca9..650eacb5e5b 100644 --- a/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts @@ -3,7 +3,7 @@ import type { CmsEntryListParams, CmsEntryResolverFactory as ResolverFactory } from "~/types/index.js"; -import { NotFoundError } from "@webiny/handler-graphql"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; type ResolveGet = ResolverFactory; @@ -16,7 +16,7 @@ export const resolveGet: ResolveGet = limit: 1 }); if (!entry) { - throw new NotFoundError(`Entry not found!`); + return new ErrorResponse(new EntryNotFoundError()); } return new Response(entry); } catch (e) { diff --git a/packages/api-headless-cms/src/utils/errors.ts b/packages/api-headless-cms/src/utils/errors.ts index 70c2be3a032..a1400fb0c25 100644 --- a/packages/api-headless-cms/src/utils/errors.ts +++ b/packages/api-headless-cms/src/utils/errors.ts @@ -1,4 +1,5 @@ import { BaseError } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; export class NotAuthorizedError extends BaseError { override readonly code = "NOT_AUTHORIZED" as const; @@ -8,4 +9,12 @@ export class NotAuthorizedError extends BaseError { message: message || "Not authorized!" }); } + + static fromModel(model: CmsModel): NotAuthorizedError { + return new NotAuthorizedError(`Not allowed to access "${model.modelId}" entries.`); + } + + static fromEntry(entry: CmsEntry): NotAuthorizedError { + return new NotAuthorizedError(`Not allowed to access entry "${entry.entryId}".`); + } } From 2a9a89e4a3872ccff7fc5d62ea95d74200da202c Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 14:59:00 +0100 Subject: [PATCH 06/71] wip: migrate api-headless-cms --- .../src/crud/contentEntry.crud.ts | 371 ++++-------------- .../createEntryRevisionFromData.ts | 2 - .../contentEntries/ContentEntriesFeature.ts | 10 + .../CreateEntryRevisionFromRepository.ts | 54 +++ .../CreateEntryRevisionFromUseCase.ts | 176 +++++++++ .../CreateEntryRevisionFrom/abstractions.ts | 97 +++++ .../CreateEntryRevisionFrom/events.ts | 71 ++++ .../CreateEntryRevisionFrom/feature.ts | 14 + .../CreateEntryRevisionFrom/index.ts | 2 + .../DeleteEntry/DeleteEntryUseCase.ts | 1 - .../DeleteEntryRevisionUseCase.ts | 2 +- .../DeleteMultipleEntriesRepository.ts | 37 ++ .../DeleteMultipleEntriesUseCase.ts | 157 ++++++++ .../DeleteMultipleEntries/abstractions.ts | 82 ++++ .../DeleteMultipleEntries/events.ts | 68 ++++ .../DeleteMultipleEntries/feature.ts | 14 + .../DeleteMultipleEntries/index.ts | 2 + .../MoveEntry/MoveEntryRepository.ts | 36 ++ .../MoveEntry/MoveEntryUseCase.ts | 120 ++++++ .../contentEntries/MoveEntry/abstractions.ts | 76 ++++ .../contentEntries/MoveEntry/events.ts | 68 ++++ .../contentEntries/MoveEntry/feature.ts | 14 + .../contentEntries/MoveEntry/index.ts | 2 + .../RepublishEntryRepository.ts | 61 +++ .../RepublishEntry/RepublishEntryUseCase.ts | 137 +++++++ .../RepublishEntry/abstractions.ts | 78 ++++ .../contentEntries/RepublishEntry/events.ts | 68 ++++ .../contentEntries/RepublishEntry/feature.ts | 14 + .../contentEntries/RepublishEntry/index.ts | 2 + .../ValidateEntry/ValidateEntryUseCase.ts | 82 ++++ .../ValidateEntry/abstractions.ts | 34 ++ .../contentEntries/ValidateEntry/feature.ts | 10 + .../contentEntries/ValidateEntry/index.ts | 1 + 33 files changed, 1655 insertions(+), 308 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index f446b935112..b022043de48 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -52,7 +52,6 @@ import type { UpdateCmsEntryInput, UpdateCmsEntryOptionsInput } from "~/types/index.js"; -import { validateModelEntryData } from "./contentEntry/entryDataValidation.js"; import { createTopic } from "@webiny/pubsub"; import { assignBeforeEntryCreate } from "./contentEntry/beforeCreate.js"; import { assignBeforeEntryUpdate } from "./contentEntry/beforeUpdate.js"; @@ -65,11 +64,9 @@ import { import { getSearchableFields } from "./contentEntry/searchableFields.js"; import { filterAsync } from "~/utils/filterAsync.js"; import { - createEntryRevisionFromData, createPublishEntryData, createRepublishEntryData, - createUnpublishEntryData, - mapAndCleanUpdatedInputData + createUnpublishEntryData } from "./contentEntry/entryDataFactories/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import { getPublishedRevisionByEntryIdUseCases } from "~/crud/contentEntry/useCases/index.js"; @@ -79,7 +76,11 @@ import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import { CreateEntryUseCase } from "~/features/contentEntries/CreateEntry/index.js"; +import { CreateEntryRevisionFromUseCase } from "~/features/contentEntries/CreateEntryRevisionFrom/abstractions.js"; import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index.js"; +import { ValidateEntryUseCase } from "~/features/contentEntries/ValidateEntry/abstractions.js"; +import { MoveEntryUseCase } from "~/features/contentEntries/MoveEntry/abstractions.js"; +import { RepublishEntryUseCase } from "~/features/contentEntries/RepublishEntry/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; import { ListLatestEntriesUseCase, @@ -95,6 +96,7 @@ import { GetRevisionsByEntryIdUseCase } from "~/features/contentEntries/GetRevis import { GetEntryUseCase } from "~/features/contentEntries/GetEntry/index.js"; import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntryRevision/index.js"; import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; +import { DeleteMultipleEntriesUseCase } from "~/features/contentEntries/DeleteMultipleEntries/abstractions.js"; import { RestoreEntryFromBinUseCase } from "~/features/contentEntries/RestoreEntryFromBin/abstractions.js"; import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; @@ -108,14 +110,7 @@ interface CreateContentEntryCrudParams { } export const createContentEntryCrud = (params: CreateContentEntryCrudParams): CmsEntryContext => { - const { - storageOperations, - accessControl, - context, - getIdentity: getSecurityIdentity, - getTenant, - getLocale - } = params; + const { storageOperations, accessControl, context, getIdentity: getSecurityIdentity } = params; /** * Create @@ -305,103 +300,30 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm return result.value as CmsEntry; }; + const createEntryRevisionFrom: CmsEntryContext["createEntryRevisionFrom"] = async ( model, sourceId, rawInput, options ) => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); - - /** - * Entries are identified by a common parent ID + Revision number. - */ - const { id: uniqueId } = parseIdentifier(sourceId); - - const useCase = context.container.resolve(GetRevisionByIdUseCase); - const originalResult = await useCase.execute(model, sourceId); + // Delegate to new CreateEntryRevisionFrom use case + const useCase = context.container.resolve(CreateEntryRevisionFromUseCase); + const result = await useCase.execute(model, sourceId, rawInput, options); - if (originalResult.isFail()) { - throw originalResult.error; - } - - const originalEntry = originalResult.value; - - const getLatestRevisionByEntryIdUseCase = context.container.resolve( - GetLatestRevisionByEntryIdUseCase - ); - - const latestStorageEntryResult = await getLatestRevisionByEntryIdUseCase.execute(model, { - id: uniqueId - }); - - if (latestStorageEntryResult.isFail()) { - throw latestStorageEntryResult.error; - } - - const latestStorageEntry = latestStorageEntryResult.value; - - const { entry, input } = await createEntryRevisionFromData({ - sourceId, - model, - rawInput, - options, - context, - getIdentity: getSecurityIdentity, - getTenant, - getLocale, - originalEntry, - latestStorageEntry, - accessControl - }); - - await accessControl.ensureCanAccessEntry({ model, entry, rwd: "w" }); - - let storageEntry: CmsStorageEntry | null = null; - - try { - await onEntryBeforeCreateRevision.publish({ - input, - entry, - original: originalEntry, - model - }); - - storageEntry = await entryToStorageTransform(context, model, entry); - - const result = await storageOperations.entries.createRevisionFrom(model, { - entry, - storageEntry - }); - - await onEntryRevisionAfterCreate.publish({ - input, - entry, - model, - original: originalEntry, - storageEntry: result - }); - return entry; - } catch (ex) { - await onEntryCreateRevisionError.publish({ - entry, - original: originalEntry, - model, - input, - error: ex - }); + if (result.isFail()) { + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - ex.message || "Could not create entry from existing one.", - ex.code || "CREATE_FROM_REVISION_ERROR", - { - error: ex, - entry, - storageEntry, - originalEntry - } + error.message || "Could not create entry from existing one.", + error.code || "CREATE_FROM_REVISION_ERROR", + error.data ); } + + return result.value; }; + const updateEntry: CmsEntryContext["updateEntry"] = async ( model: CmsModel, id: string, @@ -434,166 +356,59 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }; const validateEntry: CmsEntryContext["validateEntry"] = async (model, id, inputData) => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); - - const input = mapAndCleanUpdatedInputData(model, inputData || {}); - let originalEntry: CmsEntry | undefined; - if (id) { - /** - * The entry we are going to update. - */ - const useCase = context.container.resolve(GetRevisionByIdUseCase); - const entryResult = await useCase.execute(model, id); - - if (entryResult.isFail()) { - throw entryResult.error; - } - originalEntry = entryResult.value; - } + // Delegate to new ValidateEntry use case + const useCase = context.container.resolve(ValidateEntryUseCase); + const result = await useCase.execute(model, id || null, inputData || {}); - await accessControl.ensureCanAccessEntry({ model, entry: originalEntry, rwd: "w" }); + if (result.isFail()) { + // Convert Result error to WebinyError for backward compatibility + const error = result.error; + throw new WebinyError( + error.message || "Could not validate entry.", + error.code || "VALIDATION_ERROR", + error.data + ); + } - const result = await validateModelEntryData({ - context, - model, - data: input, - entry: originalEntry - }); - return result.length > 0 ? result : []; + return result.value; }; const moveEntry: CmsEntryContext["moveEntry"] = async (model, id, folderId) => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w" }); - - /** - * The entry we are going to move to another folder. - */ - const useCase = context.container.resolve(GetRevisionByIdUseCase); - const result = await useCase.execute(model, id); + // Delegate to new MoveEntry use case + const useCase = context.container.resolve(MoveEntryUseCase); + const result = await useCase.execute(model, id, folderId); if (result.isFail()) { - throw result.error; - } - - const entry = result.value; - - await accessControl.ensureCanAccessEntry({ model, entry, rwd: "w" }); - - /** - * No need to continue if the entry is already in the requested folder. - */ - if (entry.location?.folderId === folderId) { - return entry; + // Convert Result error to WebinyError for backward compatibility + const error = result.error; + throw new WebinyError( + error.message || `Could not move entry "${id}" of model "${model.modelId}".`, + error.code || "MOVE_ENTRY_ERROR", + error.data + ); } - try { - await onEntryBeforeMove.publish({ - entry, - model, - folderId - }); - await storageOperations.entries.move(model, id, folderId); - await onEntryAfterMove.publish({ - entry, - model, - folderId - }); - return entry; - } catch (ex) { - await onEntryMoveError.publish({ - entry, - model, - folderId, - error: ex - }); - throw WebinyError.from(ex, { - message: `Could not move entry "${id}" of model "${model.modelId}".`, - code: "MOVE_ENTRY_ERROR" - }); - } + return result.value; }; const republishEntry: CmsEntryContext["republishEntry"] = async (model, id) => { - await accessControl.ensureCanAccessEntry({ model, rwd: "w", pw: "p" }); - - /** - * Fetch the entry from the storage. - */ - const useCase = context.container.resolve(GetRevisionByIdUseCase); + // Delegate to new RepublishEntry use case + const useCase = context.container.resolve(RepublishEntryUseCase); const result = await useCase.execute(model, id); - if (result.isFail()) { - throw result.error; - } - - const originalEntry = result.value; - - await accessControl.ensureCanAccessEntry({ - model, - entry: originalEntry, - rwd: "w", - pw: "p" - }); - - const { entry } = await createRepublishEntryData({ - context, - model, - originalEntry, - getIdentity: getSecurityIdentity - }); - const storageEntry = await entryToStorageTransform(context, model, entry); - /** - * First we need to update existing entry. - */ - try { - await storageOperations.entries.update(model, { - entry, - storageEntry - }); - } catch { + if (result.isFail()) { + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - "Could not update existing entry with new data while re-publishing.", - "REPUBLISH_UPDATE_ERROR", - { - entry - } + error.message || "Could not republish entry.", + error.code || "REPUBLISH_ERROR", + error.data ); } - /** - * Then we move onto publishing it again. - */ - try { - await onEntryBeforeRepublish.publish({ - entry, - model - }); - - const result = await storageOperations.entries.publish(model, { - entry, - storageEntry - }); - await onEntryAfterRepublish.publish({ - entry, - model, - storageEntry: result - }); - return entry; - } catch (ex) { - await onEntryRepublishError.publish({ - entry, - model, - error: ex - }); - throw new WebinyError( - "Could not publish existing entry while re-publishing.", - "REPUBLISH_PUBLISH_ERROR", - { - entry - } - ); - } + return result.value; }; + const deleteEntryRevision: CmsEntryContext["deleteEntryRevision"] = async ( model, revisionId @@ -609,75 +424,21 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm model, params ) => { - const { entries: input } = params; - const maxDeletableEntries = 50; + // Delegate to new DeleteMultipleEntries use case + const useCase = context.container.resolve(DeleteMultipleEntriesUseCase); + const result = await useCase.execute(model, params); - const entryIdList = new Set(); - for (const id of input) { - const { id: entryId } = parseIdentifier(id); - entryIdList.add(entryId); - } - const ids = Array.from(entryIdList); - - if (ids.length > maxDeletableEntries) { + if (result.isFail()) { + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - "Cannot delete more than 50 entries at once.", - "DELETE_ENTRIES_MAX", - { - entries: ids - } + error.message || "Could not delete multiple entries.", + error.code || "DELETE_ENTRIES_MULTIPLE_ERROR", + error.data ); } - await accessControl.ensureCanAccessEntry({ model, rwd: "d" }); - - const { items: entries } = await storageOperations.entries.list(model, { - where: { - latest: true, - entryId_in: ids - }, - limit: maxDeletableEntries + 1 - }); - /** - * We do not want to allow deleting entries that user does not own or cannot access. - */ - const items = ( - await filterAsync(entries, async entry => { - return accessControl.canAccessEntry({ model, entry: entry }); - }) - ).map(entry => entry.id); - - try { - await onEntryBeforeDeleteMultiple.publish({ - entries, - ids, - model - }); - await storageOperations.entries.deleteMultipleEntries(model, { - entries: items - }); - await onEntryAfterDeleteMultiple.publish({ - entries, - ids, - model - }); - return items.map(id => { - return { - id - }; - }); - } catch (ex) { - await onEntryDeleteMultipleError.publish({ - entries, - ids, - model, - error: ex - }); - throw new WebinyError(ex.message, ex.code || "DELETE_ENTRIES_MULTIPLE_ERROR", { - error: ex, - entries - }); - } + return result.value; }; const publishEntry = async ( @@ -774,7 +535,9 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm }); if (!originalStorageEntry) { - const error = new NotFoundError(`Entry "${id}" of model "${model.modelId}" was not found.`); + const error = new NotFoundError( + `Entry "${id}" of model "${model.modelId}" was not found.` + ); // @ts-expect-error Temporary migration fix error.code = "ENTRY_NOT_FOUND"; throw error; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts index adbbd6f3def..5ab5341fcb9 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryRevisionFromData.ts @@ -15,7 +15,6 @@ import WebinyError from "@webiny/error"; import type { GenericRecord } from "@webiny/api/types.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import { STATUS_DRAFT, STATUS_PUBLISHED, STATUS_UNPUBLISHED } from "./statuses.js"; import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; import { getState } from "./state.js"; @@ -28,7 +27,6 @@ interface CreateEntryRevisionFromDataParams { context: CmsContext; getIdentity: () => SecurityIdentity; getTenant: () => Tenant; - getLocale: () => I18NLocale; originalEntry: CmsEntry; latestStorageEntry: CmsEntry; accessControl: AccessControl; diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts index 560994609fd..60eb47d3e78 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -1,6 +1,10 @@ import { createFeature } from "@webiny/feature/api"; import { CreateEntryFeature } from "./CreateEntry/feature.js"; +import { CreateEntryRevisionFromFeature } from "./CreateEntryRevisionFrom/feature.js"; import { UpdateEntryFeature } from "./UpdateEntry/feature.js"; +import { ValidateEntryFeature } from "./ValidateEntry/feature.js"; +import { MoveEntryFeature } from "./MoveEntry/feature.js"; +import { RepublishEntryFeature } from "./RepublishEntry/feature.js"; import { GetRevisionByIdFeature } from "./GetRevisionById/feature.js"; import { ListEntriesFeature } from "./ListEntries/feature.js"; import { GetEntriesByIdsFeature } from "./GetEntriesByIds/feature.js"; @@ -12,6 +16,7 @@ import { GetPreviousRevisionByEntryIdFeature } from "./GetPreviousRevisionByEntr import { GetEntryFeature } from "./GetEntry/feature.js"; import { DeleteEntryFeature } from "./DeleteEntry/feature.js"; import { DeleteEntryRevisionFeature } from "./DeleteEntryRevision/feature.js"; +import { DeleteMultipleEntriesFeature } from "./DeleteMultipleEntries/feature.js"; import { RestoreEntryFromBinFeature } from "./RestoreEntryFromBin/feature.js"; import { GetLatestRevisionByEntryIdFeature } from "./GetLatestRevisionByEntryId/feature.js"; @@ -32,9 +37,14 @@ export const ContentEntriesFeature = createFeature({ // Command features CreateEntryFeature.register(container); + CreateEntryRevisionFromFeature.register(container); UpdateEntryFeature.register(container); + ValidateEntryFeature.register(container); + MoveEntryFeature.register(container); + RepublishEntryFeature.register(container); DeleteEntryFeature.register(container); DeleteEntryRevisionFeature.register(container); + DeleteMultipleEntriesFeature.register(container); RestoreEntryFromBinFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts new file mode 100644 index 00000000000..c3dd1ca899b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts @@ -0,0 +1,54 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { CreateEntryRevisionFromRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * CreateEntryRevisionFromRepository - Handles storage operations for creating entry revisions. + * + * Responsibilities: + * - Transform entry to storage format + * - Call storage operation to create revision + * - Transform result back from storage format + * - Handle storage errors + */ +class CreateEntryRevisionFromRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Call storage operation + const result = await this.storageOperations.entries.createRevisionFrom(model, { + entry, + storageEntry + }); + + // Transform result back from storage format + const transformedEntry = await this.entryFromStorageTransform(model, result); + + return Result.ok(transformedEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const CreateEntryRevisionFromRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: CreateEntryRevisionFromRepositoryImpl, + dependencies: [EntryToStorageTransform, EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts new file mode 100644 index 00000000000..87039eb3242 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts @@ -0,0 +1,176 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { CreateEntryRevisionFromUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CreateEntryRevisionFromRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import type { + CmsEntry, + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "~/types/index.js"; +import { + EntryRevisionBeforeCreateEvent, + EntryRevisionAfterCreateEvent, + EntryRevisionCreateErrorEvent +} from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { parseIdentifier } from "@webiny/utils"; +import { createEntryRevisionFromData } from "~/crud/contentEntry/entryDataFactories/index.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; + +/** + * CreateEntryRevisionFromUseCase - Orchestrates creating a new revision from an existing entry. + * + * Responsibilities: + * - Apply access control + * - Get the source entry + * - Get the latest revision for version calculation + * - Prepare entry data with new version + * - Validate entry data + * - Apply additional access control based on status + * - Publish domain events + * - Delegate to repository for storage operations + */ +class CreateEntryRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: CreateEntryRevisionFromRepository.Interface, + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface, + private identityContext: IdentityContext.Interface, + private tenantContext: TenantContext.Interface, + private eventPublisher: EventPublisher.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + sourceId: string, + rawInput: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the source entry + const { id: uniqueId } = parseIdentifier(sourceId); + const originalResult = await this.getRevisionById.execute(model, sourceId); + + if (originalResult.isFail()) { + return Result.fail(originalResult.error); + } + + const originalEntry = originalResult.value; + + // Get the latest revision for version calculation + const latestResult = await this.getLatestRevision.execute(model, { id: uniqueId }); + + if (latestResult.isFail()) { + return Result.fail(latestResult.error); + } + + const latestStorageEntry = latestResult.value; + + // Prepare entry data + const { entry, input } = await createEntryRevisionFromData({ + sourceId, + model, + rawInput, + options, + context: this.cmsContext, + getIdentity: () => this.identityContext.getIdentity(), + getTenant: () => this.tenantContext.getTenant(), + originalEntry, + latestStorageEntry, + accessControl: this.accessControl as any + }); + + // Check access control on the prepared entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryRevisionBeforeCreateEvent({ + entry, + model, + input, + original: originalEntry + }) + ); + + // Delegate to repository + const result = await this.repository.execute(model, entry); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryRevisionCreateErrorEvent({ + entry, + model, + input, + original: originalEntry, + error: result.error + }) + ); + return Result.fail(result.error); + } + + const createdEntry = result.value; + + // Publish after event + await this.eventPublisher.publish( + new EntryRevisionAfterCreateEvent({ + entry: createdEntry, + model, + input, + original: originalEntry + }) + ); + + return Result.ok(createdEntry); + } catch (error) { + await this.eventPublisher.publish( + new EntryRevisionCreateErrorEvent({ + entry, + model, + input, + original: originalEntry, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const CreateEntryRevisionFromUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: CreateEntryRevisionFromUseCaseImpl, + dependencies: [ + CreateEntryRevisionFromRepository, + AccessControl, + GetRevisionByIdUseCase, + GetLatestRevisionByEntryIdUseCase, + IdentityContext, + TenantContext, + EventPublisher, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts new file mode 100644 index 00000000000..ee2d06ca639 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts @@ -0,0 +1,97 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { + CmsEntry, + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "~/types/index.js"; +import type { + EntryStorageError, + EntryValidationError, + EntryNotFoundError +} from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * CreateEntryRevisionFrom Use Case - Creates a new revision from an existing entry. + */ +export interface ICreateEntryRevisionFromUseCase { + execute( + model: CmsModel, + sourceId: string, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): Promise>; +} + +export interface ICreateEntryRevisionFromUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + validation: EntryValidationError; + storage: EntryStorageError; +} + +type UseCaseError = + ICreateEntryRevisionFromUseCaseErrors[keyof ICreateEntryRevisionFromUseCaseErrors]; + +export const CreateEntryRevisionFromUseCase = + createAbstraction("CreateEntryRevisionFromUseCase"); + +export namespace CreateEntryRevisionFromUseCase { + export type Interface = ICreateEntryRevisionFromUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before create revision event + */ +export interface EntryRevisionBeforeCreatePayload { + entry: CmsEntry; + model: CmsModel; + input: CreateCmsEntryInput; + original: CmsEntry; +} + +/** + * Payload for after create revision event + */ +export interface EntryRevisionAfterCreatePayload { + entry: CmsEntry; + model: CmsModel; + input: CreateCmsEntryInput; + original: CmsEntry; +} + +/** + * Payload for create revision error event + */ +export interface EntryRevisionCreateErrorPayload { + entry: CmsEntry; + model: CmsModel; + input: CreateCmsEntryInput; + original: CmsEntry; + error: Error; +} + +/** + * CreateEntryRevisionFromRepository - Handles storage operations for creating entry revisions. + */ +export interface ICreateEntryRevisionFromRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface ICreateEntryRevisionFromRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = + ICreateEntryRevisionFromRepositoryErrors[keyof ICreateEntryRevisionFromRepositoryErrors]; + +export const CreateEntryRevisionFromRepository = + createAbstraction("CreateEntryRevisionFromRepository"); + +export namespace CreateEntryRevisionFromRepository { + export type Interface = ICreateEntryRevisionFromRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts new file mode 100644 index 00000000000..1ec4f476285 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts @@ -0,0 +1,71 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryRevisionBeforeCreatePayload, + EntryRevisionAfterCreatePayload, + EntryRevisionCreateErrorPayload +} from "./abstractions.js"; + +/** + * Before create entry revision event + */ +export class EntryRevisionBeforeCreateEvent extends DomainEvent { + eventType = "entry.revision.beforeCreate" as const; + + getHandlerAbstraction() { + return EntryRevisionBeforeCreateHandler; + } +} + +export const EntryRevisionBeforeCreateHandler = + createAbstraction>( + "EntryRevisionBeforeCreateHandler" + ); + +export namespace EntryRevisionBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionBeforeCreateEvent; +} + +/** + * After create entry revision event + */ +export class EntryRevisionAfterCreateEvent extends DomainEvent { + eventType = "entry.revision.afterCreate" as const; + + getHandlerAbstraction() { + return EntryRevisionAfterCreateHandler; + } +} + +export const EntryRevisionAfterCreateHandler = + createAbstraction>( + "EntryRevisionAfterCreateHandler" + ); + +export namespace EntryRevisionAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionAfterCreateEvent; +} + +/** + * Create entry revision error event + */ +export class EntryRevisionCreateErrorEvent extends DomainEvent { + eventType = "entry.revision.createError" as const; + + getHandlerAbstraction() { + return EntryRevisionCreateErrorHandler; + } +} + +export const EntryRevisionCreateErrorHandler = + createAbstraction>( + "EntryRevisionCreateErrorHandler" + ); + +export namespace EntryRevisionCreateErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryRevisionCreateErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts new file mode 100644 index 00000000000..8e38be5763e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateEntryRevisionFromUseCase } from "./CreateEntryRevisionFromUseCase.js"; +import { CreateEntryRevisionFromRepository } from "./CreateEntryRevisionFromRepository.js"; + +export const CreateEntryRevisionFromFeature = createFeature({ + name: "CreateEntryRevisionFrom", + register(container) { + // Register repository (singleton scope) + container.register(CreateEntryRevisionFromRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(CreateEntryRevisionFromUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts index dddffc23458..c28aa460aee 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts @@ -15,7 +15,6 @@ import { NotAuthorizedError } from "~/utils/errors.js"; * Responsibilities: * - Apply access control * - Get the entry to delete by ID - * - TODO: Handle force delete (for cleanup when entry might not exist in storage), and `permanently` * - Publish domain events * - Delegate to repository for storage operations */ diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index fe76258089b..dd5af60f527 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -89,11 +89,11 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { // If targeted record is the latest entry record and there is no previous revision, // delete the entire entry. + const previousRevision = previousRevisionResult.isFail() ? null : previousRevisionResult.value; if (previousRevisionResult.isFail() && entryToDelete.id === latestRevisionId) { return await this.deleteEntry.execute(model, revisionId, {}); } - const previousRevision = previousRevisionResult.value; // Determine the entry to set as latest (if deleting current latest) let latestEntry = null; if (entryToDelete.id === latestRevisionId && previousRevision) { diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts new file mode 100644 index 00000000000..e2914a6227b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts @@ -0,0 +1,37 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteMultipleEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * DeleteMultipleEntriesRepository - Handles storage operations for deleting multiple entries. + * + * Responsibilities: + * - Call storage operation to delete multiple entries + * - Handle storage errors + */ +class DeleteMultipleEntriesRepositoryImpl implements RepositoryAbstraction.Interface { + constructor(private storageOperations: StorageOperations.Interface) {} + + async execute( + model: CmsModel, + entryIds: string[] + ): Promise> { + try { + await this.storageOperations.entries.deleteMultipleEntries(model, { + entries: entryIds + }); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const DeleteMultipleEntriesRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: DeleteMultipleEntriesRepositoryImpl, + dependencies: [StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts new file mode 100644 index 00000000000..a8319f97949 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts @@ -0,0 +1,157 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { DeleteMultipleEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { DeleteMultipleEntriesRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { ListEntriesUseCase } from "~/features/contentEntries/ListEntries/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; +import { + EntryBeforeDeleteMultipleEvent, + EntryAfterDeleteMultipleEvent, + EntryDeleteMultipleErrorEvent +} from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { parseIdentifier } from "@webiny/utils"; +import WebinyError from "@webiny/error"; +import { filterAsync } from "~/utils/filterAsync.js"; + +/** + * DeleteMultipleEntriesUseCase - Orchestrates deleting multiple entries. + * + * Responsibilities: + * - Validate max entries limit (50) + * - Parse entry IDs to unique entry IDs + * - Apply access control + * - Fetch entries using ListEntries use case + * - Filter entries by access control + * - Publish domain events + * - Delegate to repository for storage operations + */ +class DeleteMultipleEntriesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: DeleteMultipleEntriesRepository.Interface, + private accessControl: AccessControl.Interface, + private listEntries: ListEntriesUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + model: CmsModel, + params: { entries: string[] } + ): Promise, UseCaseAbstraction.Error>> { + const { entries: input } = params; + const maxDeletableEntries = 50; + + // Parse entry IDs to unique entry IDs + const entryIdList = new Set(); + for (const id of input) { + const { id: entryId } = parseIdentifier(id); + entryIdList.add(entryId); + } + const ids = Array.from(entryIdList); + + // Validate max entries limit + if (ids.length > maxDeletableEntries) { + return Result.fail( + new WebinyError( + "Cannot delete more than 50 entries at once.", + "DELETE_ENTRIES_MAX", + { + entries: ids + } + ) as any + ); + } + + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Fetch entries using ListEntries use case + const listResult = await this.listEntries.execute(model, { + where: { + latest: true, + entryId_in: ids + }, + limit: maxDeletableEntries + 1 + }); + + if (listResult.isFail()) { + return Result.fail(listResult.error); + } + + const entries = listResult.value[0]; + + // Filter entries by access control (only delete entries user can access) + const accessibleEntries = await filterAsync(entries, async entry => { + return this.accessControl.canAccessEntry({ model, entry }); + }); + + const items = accessibleEntries.map(entry => entry.id); + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeDeleteMultipleEvent({ + entries, + ids, + model + }) + ); + + // Delegate to repository + const result = await this.repository.execute(model, items); + + if (result.isFail()) { + await this.eventPublisher.publish( + new EntryDeleteMultipleErrorEvent({ + entries, + ids, + model, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterDeleteMultipleEvent({ + entries, + ids, + model + }) + ); + + return Result.ok( + items.map(id => { + return { id }; + }) + ); + } catch (error) { + await this.eventPublisher.publish( + new EntryDeleteMultipleErrorEvent({ + entries, + ids, + model, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const DeleteMultipleEntriesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: DeleteMultipleEntriesUseCaseImpl, + dependencies: [ + DeleteMultipleEntriesRepository, + AccessControl, + ListEntriesUseCase, + EventPublisher + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts new file mode 100644 index 00000000000..25b0bf0df9d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts @@ -0,0 +1,82 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteMultipleEntries Use Case - Deletes multiple entries at once. + */ +export interface IDeleteMultipleEntriesUseCase { + execute( + model: CmsModel, + params: { entries: string[] } + ): Promise, UseCaseError>>; +} + +export interface IDeleteMultipleEntriesUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; +} + +type UseCaseError = IDeleteMultipleEntriesUseCaseErrors[keyof IDeleteMultipleEntriesUseCaseErrors]; + +export const DeleteMultipleEntriesUseCase = createAbstraction( + "DeleteMultipleEntriesUseCase" +); + +export namespace DeleteMultipleEntriesUseCase { + export type Interface = IDeleteMultipleEntriesUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before delete multiple event + */ +export interface EntryBeforeDeleteMultiplePayload { + entries: CmsEntry[]; + ids: string[]; + model: CmsModel; +} + +/** + * Payload for after delete multiple event + */ +export interface EntryAfterDeleteMultiplePayload { + entries: CmsEntry[]; + ids: string[]; + model: CmsModel; +} + +/** + * Payload for delete multiple error event + */ +export interface EntryDeleteMultipleErrorPayload { + entries: CmsEntry[]; + ids: string[]; + model: CmsModel; + error: Error; +} + +/** + * DeleteMultipleEntriesRepository - Handles storage operations for deleting multiple entries. + */ +export interface IDeleteMultipleEntriesRepository { + execute(model: CmsModel, entryIds: string[]): Promise>; +} + +export interface IDeleteMultipleEntriesRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = + IDeleteMultipleEntriesRepositoryErrors[keyof IDeleteMultipleEntriesRepositoryErrors]; + +export const DeleteMultipleEntriesRepository = createAbstraction( + "DeleteMultipleEntriesRepository" +); + +export namespace DeleteMultipleEntriesRepository { + export type Interface = IDeleteMultipleEntriesRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts new file mode 100644 index 00000000000..61a887f6fa6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforeDeleteMultiplePayload, + EntryAfterDeleteMultiplePayload, + EntryDeleteMultipleErrorPayload +} from "./abstractions.js"; + +/** + * Before delete multiple entries event + */ +export class EntryBeforeDeleteMultipleEvent extends DomainEvent { + eventType = "entry.beforeDeleteMultiple" as const; + + getHandlerAbstraction() { + return EntryBeforeDeleteMultipleHandler; + } +} + +export const EntryBeforeDeleteMultipleHandler = createAbstraction< + IEventHandler +>("EntryBeforeDeleteMultipleHandler"); + +export namespace EntryBeforeDeleteMultipleHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeDeleteMultipleEvent; +} + +/** + * After delete multiple entries event + */ +export class EntryAfterDeleteMultipleEvent extends DomainEvent { + eventType = "entry.afterDeleteMultiple" as const; + + getHandlerAbstraction() { + return EntryAfterDeleteMultipleHandler; + } +} + +export const EntryAfterDeleteMultipleHandler = createAbstraction< + IEventHandler +>("EntryAfterDeleteMultipleHandler"); + +export namespace EntryAfterDeleteMultipleHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterDeleteMultipleEvent; +} + +/** + * Delete multiple entries error event + */ +export class EntryDeleteMultipleErrorEvent extends DomainEvent { + eventType = "entry.deleteMultipleError" as const; + + getHandlerAbstraction() { + return EntryDeleteMultipleErrorHandler; + } +} + +export const EntryDeleteMultipleErrorHandler = createAbstraction< + IEventHandler +>("EntryDeleteMultipleErrorHandler"); + +export namespace EntryDeleteMultipleErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryDeleteMultipleErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts new file mode 100644 index 00000000000..ae814a5ee54 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteMultipleEntriesUseCase } from "./DeleteMultipleEntriesUseCase.js"; +import { DeleteMultipleEntriesRepository } from "./DeleteMultipleEntriesRepository.js"; + +export const DeleteMultipleEntriesFeature = createFeature({ + name: "DeleteMultipleEntries", + register(container) { + // Register repository (singleton scope) + container.register(DeleteMultipleEntriesRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(DeleteMultipleEntriesUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts new file mode 100644 index 00000000000..4da9cd1ef19 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts @@ -0,0 +1,36 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { MoveEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * MoveEntryRepository - Handles storage operations for moving entries. + * + * Responsibilities: + * - Call storage operation to move entry to different folder + * - Handle storage errors + */ +class MoveEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor(private storageOperations: StorageOperations.Interface) {} + + async execute( + model: CmsModel, + id: string, + folderId: string + ): Promise> { + try { + await this.storageOperations.entries.move(model, id, folderId); + return Result.ok(); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const MoveEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: MoveEntryRepositoryImpl, + dependencies: [StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts new file mode 100644 index 00000000000..f51287275cb --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts @@ -0,0 +1,120 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { MoveEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { MoveEntryRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { EntryBeforeMoveEvent, EntryAfterMoveEvent, EntryMoveErrorEvent } from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * MoveEntryUseCase - Orchestrates moving an entry to a different folder. + * + * Responsibilities: + * - Apply access control + * - Get the entry to move + * - Check if entry is already in target folder (early return) + * - Publish domain events + * - Delegate to repository for storage operations + */ +class MoveEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: MoveEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + folderId: string + ): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the entry to move + const result = await this.getRevisionById.execute(model, id); + + if (result.isFail()) { + return Result.fail(new EntryNotFoundError(id)); + } + + const entry = result.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Early return if entry is already in the requested folder + if (entry.location?.folderId === folderId) { + return Result.ok(entry); + } + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeMoveEvent({ + entry, + model, + folderId + }) + ); + + // Delegate to repository + const moveResult = await this.repository.execute(model, id, folderId); + + if (moveResult.isFail()) { + await this.eventPublisher.publish( + new EntryMoveErrorEvent({ + entry, + model, + folderId, + error: moveResult.error + }) + ); + return Result.fail(moveResult.error); + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterMoveEvent({ + entry, + model, + folderId + }) + ); + + return Result.ok(entry); + } catch (error) { + await this.eventPublisher.publish( + new EntryMoveErrorEvent({ + entry, + model, + folderId, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const MoveEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: MoveEntryUseCaseImpl, + dependencies: [MoveEntryRepository, AccessControl, GetRevisionByIdUseCase, EventPublisher] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts new file mode 100644 index 00000000000..49b6aa3405a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts @@ -0,0 +1,76 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * MoveEntry Use Case - Moves an entry to a different folder. + */ +export interface IMoveEntryUseCase { + execute(model: CmsModel, id: string, folderId: string): Promise>; +} + +export interface IMoveEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IMoveEntryUseCaseErrors[keyof IMoveEntryUseCaseErrors]; + +export const MoveEntryUseCase = createAbstraction("MoveEntryUseCase"); + +export namespace MoveEntryUseCase { + export type Interface = IMoveEntryUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before move event + */ +export interface EntryBeforeMovePayload { + entry: CmsEntry; + model: CmsModel; + folderId: string; +} + +/** + * Payload for after move event + */ +export interface EntryAfterMovePayload { + entry: CmsEntry; + model: CmsModel; + folderId: string; +} + +/** + * Payload for move error event + */ +export interface EntryMoveErrorPayload { + entry: CmsEntry; + model: CmsModel; + folderId: string; + error: Error; +} + +/** + * MoveEntryRepository - Handles storage operations for moving entries. + */ +export interface IMoveEntryRepository { + execute(model: CmsModel, id: string, folderId: string): Promise>; +} + +export interface IMoveEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IMoveEntryRepositoryErrors[keyof IMoveEntryRepositoryErrors]; + +export const MoveEntryRepository = createAbstraction("MoveEntryRepository"); + +export namespace MoveEntryRepository { + export type Interface = IMoveEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts new file mode 100644 index 00000000000..b6609a1b17a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforeMovePayload, + EntryAfterMovePayload, + EntryMoveErrorPayload +} from "./abstractions.js"; + +/** + * Before move entry event + */ +export class EntryBeforeMoveEvent extends DomainEvent { + eventType = "entry.beforeMove" as const; + + getHandlerAbstraction() { + return EntryBeforeMoveHandler; + } +} + +export const EntryBeforeMoveHandler = createAbstraction>( + "EntryBeforeMoveHandler" +); + +export namespace EntryBeforeMoveHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeMoveEvent; +} + +/** + * After move entry event + */ +export class EntryAfterMoveEvent extends DomainEvent { + eventType = "entry.afterMove" as const; + + getHandlerAbstraction() { + return EntryAfterMoveHandler; + } +} + +export const EntryAfterMoveHandler = createAbstraction>( + "EntryAfterMoveHandler" +); + +export namespace EntryAfterMoveHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterMoveEvent; +} + +/** + * Move entry error event + */ +export class EntryMoveErrorEvent extends DomainEvent { + eventType = "entry.moveError" as const; + + getHandlerAbstraction() { + return EntryMoveErrorHandler; + } +} + +export const EntryMoveErrorHandler = createAbstraction>( + "EntryMoveErrorHandler" +); + +export namespace EntryMoveErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryMoveErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts new file mode 100644 index 00000000000..26827d9483d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { MoveEntryUseCase } from "./MoveEntryUseCase.js"; +import { MoveEntryRepository } from "./MoveEntryRepository.js"; + +export const MoveEntryFeature = createFeature({ + name: "MoveEntry", + register(container) { + // Register repository (singleton scope) + container.register(MoveEntryRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(MoveEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts new file mode 100644 index 00000000000..9e819a100b0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts @@ -0,0 +1,61 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { RepublishEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * RepublishEntryRepository - Handles storage operations for republishing entries. + * + * Responsibilities: + * - Transform entry to storage format + * - Update the entry in storage + * - Publish the entry in storage + * - Transform result back from storage format + * - Handle storage errors + */ +class RepublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // First update the entry + await this.storageOperations.entries.update(model, { + entry, + storageEntry + }); + + // Then publish it + const result = await this.storageOperations.entries.publish(model, { + entry, + storageEntry + }); + + // Transform result back from storage format + const transformedEntry = await this.entryFromStorageTransform(model, result); + + return Result.ok(transformedEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const RepublishEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: RepublishEntryRepositoryImpl, + dependencies: [EntryToStorageTransform, EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts new file mode 100644 index 00000000000..9eb7b495e3a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts @@ -0,0 +1,137 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { RepublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { RepublishEntryRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { + EntryBeforeRepublishEvent, + EntryAfterRepublishEvent, + EntryRepublishErrorEvent +} from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { createRepublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; + +/** + * RepublishEntryUseCase - Orchestrates republishing an entry. + * + * Responsibilities: + * - Apply access control (both write and publish permissions) + * - Get the entry to republish + * - Prepare entry data with updated timestamps + * - Publish domain events + * - Delegate to repository for storage operations (update + publish) + */ +class RepublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: RepublishEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private identityContext: IdentityContext.Interface, + private eventPublisher: EventPublisher.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + // Check access control (write and publish) + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w", pw: "p" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the entry to republish + const result = await this.getRevisionById.execute(model, id); + + if (result.isFail()) { + return Result.fail(new EntryNotFoundError(id)); + } + + const originalEntry = result.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + rwd: "w", + pw: "p" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Prepare entry data for republishing + const { entry } = await createRepublishEntryData({ + context: this.cmsContext, + model, + originalEntry, + getIdentity: () => this.identityContext.getIdentity() + }); + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeRepublishEvent({ + entry, + model + }) + ); + + // Delegate to repository (update + publish) + const repositoryResult = await this.repository.execute(model, entry); + + if (repositoryResult.isFail()) { + await this.eventPublisher.publish( + new EntryRepublishErrorEvent({ + entry, + model, + error: repositoryResult.error + }) + ); + return Result.fail(repositoryResult.error); + } + + const publishedEntry = repositoryResult.value; + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterRepublishEvent({ + entry: publishedEntry, + model + }) + ); + + return Result.ok(entry); + } catch (error) { + await this.eventPublisher.publish( + new EntryRepublishErrorEvent({ + entry, + model, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const RepublishEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: RepublishEntryUseCaseImpl, + dependencies: [ + RepublishEntryRepository, + AccessControl, + GetRevisionByIdUseCase, + IdentityContext, + EventPublisher, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts new file mode 100644 index 00000000000..710dcd468fd --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts @@ -0,0 +1,78 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * RepublishEntry Use Case - Republishes an already published entry. + * This updates the entry and publishes it again. + */ +export interface IRepublishEntryUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IRepublishEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + storage: EntryStorageError; +} + +type UseCaseError = IRepublishEntryUseCaseErrors[keyof IRepublishEntryUseCaseErrors]; + +export const RepublishEntryUseCase = createAbstraction( + "RepublishEntryUseCase" +); + +export namespace RepublishEntryUseCase { + export type Interface = IRepublishEntryUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before republish event + */ +export interface EntryBeforeRepublishPayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for after republish event + */ +export interface EntryAfterRepublishPayload { + entry: CmsEntry; + model: CmsModel; +} + +/** + * Payload for republish error event + */ +export interface EntryRepublishErrorPayload { + entry: CmsEntry; + model: CmsModel; + error: Error; +} + +/** + * RepublishEntryRepository - Handles storage operations for republishing entries. + */ +export interface IRepublishEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IRepublishEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IRepublishEntryRepositoryErrors[keyof IRepublishEntryRepositoryErrors]; + +export const RepublishEntryRepository = createAbstraction( + "RepublishEntryRepository" +); + +export namespace RepublishEntryRepository { + export type Interface = IRepublishEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts new file mode 100644 index 00000000000..07bb2f56def --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforeRepublishPayload, + EntryAfterRepublishPayload, + EntryRepublishErrorPayload +} from "./abstractions.js"; + +/** + * Before republish entry event + */ +export class EntryBeforeRepublishEvent extends DomainEvent { + eventType = "entry.beforeRepublish" as const; + + getHandlerAbstraction() { + return EntryBeforeRepublishHandler; + } +} + +export const EntryBeforeRepublishHandler = createAbstraction< + IEventHandler +>("EntryBeforeRepublishHandler"); + +export namespace EntryBeforeRepublishHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeRepublishEvent; +} + +/** + * After republish entry event + */ +export class EntryAfterRepublishEvent extends DomainEvent { + eventType = "entry.afterRepublish" as const; + + getHandlerAbstraction() { + return EntryAfterRepublishHandler; + } +} + +export const EntryAfterRepublishHandler = createAbstraction< + IEventHandler +>("EntryAfterRepublishHandler"); + +export namespace EntryAfterRepublishHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterRepublishEvent; +} + +/** + * Republish entry error event + */ +export class EntryRepublishErrorEvent extends DomainEvent { + eventType = "entry.republishError" as const; + + getHandlerAbstraction() { + return EntryRepublishErrorHandler; + } +} + +export const EntryRepublishErrorHandler = createAbstraction< + IEventHandler +>("EntryRepublishErrorHandler"); + +export namespace EntryRepublishErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryRepublishErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts new file mode 100644 index 00000000000..4508ac37fda --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { RepublishEntryUseCase } from "./RepublishEntryUseCase.js"; +import { RepublishEntryRepository } from "./RepublishEntryRepository.js"; + +export const RepublishEntryFeature = createFeature({ + name: "RepublishEntry", + register(container) { + // Register repository (singleton scope) + container.register(RepublishEntryRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(RepublishEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts new file mode 100644 index 00000000000..a4400ed1110 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts @@ -0,0 +1,82 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ValidateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { mapAndCleanUpdatedInputData } from "~/crud/contentEntry/entryDataFactories/index.js"; +import { validateModelEntryData } from "~/crud/contentEntry/entryDataValidation.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; + +/** + * ValidateEntryUseCase - Orchestrates entry data validation. + * + * Responsibilities: + * - Apply access control + * - Optionally get the entry being validated (if id provided) + * - Map and clean input data + * - Validate data against model field validators + * - Return validation results + */ +class ValidateEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + id: string | null, + inputData: Record + ): Promise> { + // Check access control + const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Map and clean input data + const input = mapAndCleanUpdatedInputData(model, inputData || {}); + + // Optionally get the entry being validated + let originalEntry = undefined; + if (id) { + const entryResult = await this.getRevisionById.execute(model, id); + + if (entryResult.isFail()) { + return Result.fail(entryResult.error); + } + + originalEntry = entryResult.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + rwd: "w" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + } + + // Validate the data + const validationResult = await validateModelEntryData({ + context: this.cmsContext, + model, + data: input, + entry: originalEntry + }); + + return Result.ok(validationResult.length > 0 ? validationResult : []); + } +} + +export const ValidateEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ValidateEntryUseCaseImpl, + dependencies: [AccessControl, GetRevisionByIdUseCase, CmsContext] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts new file mode 100644 index 00000000000..22283daa8ee --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts @@ -0,0 +1,34 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; + +/** + * ValidateEntry Use Case - Validates entry data against model field validators. + * This can be used to validate data before creating or updating an entry. + */ +export interface IValidateEntryUseCase { + execute( + model: CmsModel, + id: string | null, + inputData: Record + ): Promise>; +} + +export interface IValidateEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + getRevisionById: GetRevisionByIdUseCase.Error; +} + +type UseCaseError = IValidateEntryUseCaseErrors[keyof IValidateEntryUseCaseErrors]; + +export const ValidateEntryUseCase = + createAbstraction("ValidateEntryUseCase"); + +export namespace ValidateEntryUseCase { + export type Interface = IValidateEntryUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts new file mode 100644 index 00000000000..f0aded09b44 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts @@ -0,0 +1,10 @@ +import { createFeature } from "@webiny/feature/api"; +import { ValidateEntryUseCase } from "./ValidateEntryUseCase.js"; + +export const ValidateEntryFeature = createFeature({ + name: "ValidateEntry", + register(container) { + // Register use case (transient scope) + container.register(ValidateEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; From 8dca761593e1142a6f4ce54b41e47a57c9a908a9 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 21:16:42 +0100 Subject: [PATCH 07/71] wip: migrate api-headless-cms --- .../src/crud/contentEntry.crud.ts | 89 ++-------- .../contentEntries/ContentEntriesFeature.ts | 2 + .../PublishEntry/PublishEntryRepository.ts | 54 ++++++ .../PublishEntry/PublishEntryUseCase.ts | 157 ++++++++++++++++++ .../PublishEntry/abstractions.ts | 79 +++++++++ .../contentEntries/PublishEntry/events.ts | 68 ++++++++ .../contentEntries/PublishEntry/feature.ts | 14 ++ .../contentEntries/PublishEntry/index.ts | 2 + 8 files changed, 387 insertions(+), 78 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index b022043de48..aee26a80212 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -62,12 +62,7 @@ import { entryToStorageTransform } from "~/utils/entryStorage.js"; import { getSearchableFields } from "./contentEntry/searchableFields.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import { - createPublishEntryData, - createRepublishEntryData, - createUnpublishEntryData -} from "./contentEntry/entryDataFactories/index.js"; +import { createUnpublishEntryData } from "./contentEntry/entryDataFactories/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import { getPublishedRevisionByEntryIdUseCases } from "~/crud/contentEntry/useCases/index.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; @@ -81,7 +76,7 @@ import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index. import { ValidateEntryUseCase } from "~/features/contentEntries/ValidateEntry/abstractions.js"; import { MoveEntryUseCase } from "~/features/contentEntries/MoveEntry/abstractions.js"; import { RepublishEntryUseCase } from "~/features/contentEntries/RepublishEntry/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { PublishEntryUseCase } from "~/features/contentEntries/PublishEntry/abstractions.js"; import { ListLatestEntriesUseCase, ListPublishedEntriesUseCase, @@ -98,7 +93,6 @@ import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntr import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; import { DeleteMultipleEntriesUseCase } from "~/features/contentEntries/DeleteMultipleEntries/abstractions.js"; import { RestoreEntryFromBinUseCase } from "~/features/contentEntries/RestoreEntryFromBin/abstractions.js"; -import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; interface CreateContentEntryCrudParams { storageOperations: HeadlessCmsStorageOperations; @@ -445,82 +439,21 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm model: CmsModel, id: string ) => { - await accessControl.ensureCanAccessEntry({ model, pw: "p" }); - - const useCase = context.container.resolve(GetRevisionByIdUseCase); + // Delegate to new PublishEntry use case + const useCase = context.container.resolve(PublishEntryUseCase); const result = await useCase.execute(model, id); if (result.isFail()) { - throw result.error; - } - - const originalEntry = result.value; - - await accessControl.ensureCanAccessEntry({ model, entry: originalEntry, pw: "p" }); - - const getLatestRevisionByEntryIdUseCase = context.container.resolve( - GetLatestRevisionByEntryIdUseCase - ); - // We need the latest entry to get the latest entry-level meta fields. - const latestStorageEntryResult = await getLatestRevisionByEntryIdUseCase.execute(model, { - id: originalEntry.entryId - }); - - if (latestStorageEntryResult.isFail()) { - throw latestStorageEntryResult.error; - } - - const latestStorageEntry = latestStorageEntryResult.value; - const latestEntry = await entryFromStorageTransform(context, model, latestStorageEntry); - - const { entry } = await createPublishEntryData({ - context, - model, - originalEntry, - latestEntry, - getIdentity: getSecurityIdentity - }); - - let storageEntry: CmsStorageEntry | null = null; - - try { - await onEntryBeforePublish.publish({ - original: originalEntry, - entry, - model - }); - - storageEntry = await entryToStorageTransform(context, model, entry); - const result = await storageOperations.entries.publish(model, { - entry, - storageEntry - }); - - await onEntryAfterPublish.publish({ - original: originalEntry, - entry, - storageEntry: result, - model - }); - return entry; - } catch (ex) { - await onEntryPublishError.publish({ - original: originalEntry, - entry, - model, - error: ex - }); + // Convert Result error to WebinyError for backward compatibility + const error = result.error; throw new WebinyError( - ex.message || "Could not publish entry.", - ex.code || "PUBLISH_ERROR", - { - error: ex, - entry, - storageEntry, - originalEntry - } + error.message || "Could not publish entry.", + error.code || "PUBLISH_ERROR", + error.data ); } + + return result.value as CmsEntry; }; const unpublishEntry = async ( model: CmsModel, diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts index 60eb47d3e78..63dcfffc8e4 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -5,6 +5,7 @@ import { UpdateEntryFeature } from "./UpdateEntry/feature.js"; import { ValidateEntryFeature } from "./ValidateEntry/feature.js"; import { MoveEntryFeature } from "./MoveEntry/feature.js"; import { RepublishEntryFeature } from "./RepublishEntry/feature.js"; +import { PublishEntryFeature } from "./PublishEntry/feature.js"; import { GetRevisionByIdFeature } from "./GetRevisionById/feature.js"; import { ListEntriesFeature } from "./ListEntries/feature.js"; import { GetEntriesByIdsFeature } from "./GetEntriesByIds/feature.js"; @@ -41,6 +42,7 @@ export const ContentEntriesFeature = createFeature({ UpdateEntryFeature.register(container); ValidateEntryFeature.register(container); MoveEntryFeature.register(container); + PublishEntryFeature.register(container); RepublishEntryFeature.register(container); DeleteEntryFeature.register(container); DeleteEntryRevisionFeature.register(container); diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts new file mode 100644 index 00000000000..013ff644f6c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts @@ -0,0 +1,54 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { PublishEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * PublishEntryRepository - Handles storage operations for publishing entries. + * + * Responsibilities: + * - Transform entry to storage format + * - Publish the entry in storage + * - Transform result back from storage format + * - Handle storage errors + */ +class PublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Publish the entry + const result = await this.storageOperations.entries.publish(model, { + entry, + storageEntry + }); + + // Transform result back from storage format + const transformedEntry = await this.entryFromStorageTransform(model, result); + + return Result.ok(transformedEntry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const PublishEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: PublishEntryRepositoryImpl, + dependencies: [EntryToStorageTransform, EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts new file mode 100644 index 00000000000..31fc85ffca5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts @@ -0,0 +1,157 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { PublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { PublishEntryRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import { + EntryBeforePublishEvent, + EntryAfterPublishEvent, + EntryPublishErrorEvent +} from "./events.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { createPublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; + +/** + * PublishEntryUseCase - Orchestrates publishing an entry. + * + * Responsibilities: + * - Apply access control (publish permission) + * - Get the entry to publish + * - Get the latest revision for entry-level metadata + * - Prepare entry data with publish metadata + * - Validate entry data + * - Publish domain events + * - Delegate to repository for storage operations + */ +class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: PublishEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private getLatestRevision: GetLatestRevisionByEntryIdUseCase.Interface, + private identityContext: IdentityContext.Interface, + private eventPublisher: EventPublisher.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + // Check access control (publish permission) + const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the entry to publish + const result = await this.getRevisionById.execute(model, id); + + if (result.isFail()) { + return Result.fail(new EntryNotFoundError(id)); + } + + const originalEntry = result.value; + + // Check access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + pw: "p" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Get the latest revision for entry-level metadata + const latestResult = await this.getLatestRevision.execute(model, { + id: originalEntry.entryId + }); + + if (latestResult.isFail()) { + return Result.fail(latestResult.error); + } + + const latestEntry = latestResult.value; + + // Prepare entry data for publishing (includes validation) + const { entry } = await createPublishEntryData({ + context: this.cmsContext, + model, + originalEntry, + latestEntry, + getIdentity: () => this.identityContext.getIdentity() + }); + + try { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforePublishEvent({ + entry, + original: originalEntry, + model + }) + ); + + // Delegate to repository + const repositoryResult = await this.repository.execute(model, entry); + + if (repositoryResult.isFail()) { + await this.eventPublisher.publish( + new EntryPublishErrorEvent({ + entry, + original: originalEntry, + model, + error: repositoryResult.error + }) + ); + return Result.fail(repositoryResult.error); + } + + const publishedEntry = repositoryResult.value; + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterPublishEvent({ + entry: publishedEntry, + original: originalEntry, + model + }) + ); + + return Result.ok(entry); + } catch (error) { + await this.eventPublisher.publish( + new EntryPublishErrorEvent({ + entry, + original: originalEntry, + model, + error: error as Error + }) + ); + return Result.fail(error as any); + } + } +} + +export const PublishEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: PublishEntryUseCaseImpl, + dependencies: [ + PublishEntryRepository, + AccessControl, + GetRevisionByIdUseCase, + GetLatestRevisionByEntryIdUseCase, + IdentityContext, + EventPublisher, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts new file mode 100644 index 00000000000..57c8b98e8c6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts @@ -0,0 +1,79 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; +import type { EntryStorageError, EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; + +/** + * PublishEntry Use Case - Publishes an entry revision. + */ +export interface IPublishEntryUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IPublishEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + validation: EntryValidationError; + storage: EntryStorageError; +} + +type UseCaseError = IPublishEntryUseCaseErrors[keyof IPublishEntryUseCaseErrors]; + +export const PublishEntryUseCase = createAbstraction("PublishEntryUseCase"); + +export namespace PublishEntryUseCase { + export type Interface = IPublishEntryUseCase; + export type Error = UseCaseError; +} + +/** + * Payload for before publish event + */ +export interface EntryBeforePublishPayload { + entry: CmsEntry; + original: CmsEntry; + model: CmsModel; +} + +/** + * Payload for after publish event + */ +export interface EntryAfterPublishPayload { + entry: CmsEntry; + original: CmsEntry; + model: CmsModel; +} + +/** + * Payload for publish error event + */ +export interface EntryPublishErrorPayload { + entry: CmsEntry; + original: CmsEntry; + model: CmsModel; + error: Error; +} + +/** + * PublishEntryRepository - Handles storage operations for publishing entries. + */ +export interface IPublishEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IPublishEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IPublishEntryRepositoryErrors[keyof IPublishEntryRepositoryErrors]; + +export const PublishEntryRepository = createAbstraction( + "PublishEntryRepository" +); + +export namespace PublishEntryRepository { + export type Interface = IPublishEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts new file mode 100644 index 00000000000..cd7872b1914 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { + EntryBeforePublishPayload, + EntryAfterPublishPayload, + EntryPublishErrorPayload +} from "./abstractions.js"; + +/** + * Before publish entry event + */ +export class EntryBeforePublishEvent extends DomainEvent { + eventType = "entry.beforePublish" as const; + + getHandlerAbstraction() { + return EntryBeforePublishHandler; + } +} + +export const EntryBeforePublishHandler = createAbstraction>( + "EntryBeforePublishHandler" +); + +export namespace EntryBeforePublishHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforePublishEvent; +} + +/** + * After publish entry event + */ +export class EntryAfterPublishEvent extends DomainEvent { + eventType = "entry.afterPublish" as const; + + getHandlerAbstraction() { + return EntryAfterPublishHandler; + } +} + +export const EntryAfterPublishHandler = createAbstraction>( + "EntryAfterPublishHandler" +); + +export namespace EntryAfterPublishHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterPublishEvent; +} + +/** + * Publish entry error event + */ +export class EntryPublishErrorEvent extends DomainEvent { + eventType = "entry.publishError" as const; + + getHandlerAbstraction() { + return EntryPublishErrorHandler; + } +} + +export const EntryPublishErrorHandler = createAbstraction>( + "EntryPublishErrorHandler" +); + +export namespace EntryPublishErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryPublishErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts new file mode 100644 index 00000000000..8c279d396ec --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { PublishEntryUseCase } from "./PublishEntryUseCase.js"; +import { PublishEntryRepository } from "./PublishEntryRepository.js"; + +export const PublishEntryFeature = createFeature({ + name: "PublishEntry", + register(container) { + // Register repository (singleton scope) + container.register(PublishEntryRepository).inSingletonScope(); + + // Register use case (transient scope) + container.register(PublishEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; From 8a49dba98ae6a6babe72bb732e2847fe77ab530a Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 21:38:11 +0100 Subject: [PATCH 08/71] wip: migrate api-headless-cms --- packages/api-headless-cms/src/context.ts | 7 +- .../src/crud/contentEntry.crud.ts | 210 ++---------------- .../createUnpublishEntryData.ts | 4 +- .../contentEntries/ContentEntriesFeature.ts | 6 + ...GetPublishedRevisionByEntryIdRepository.ts | 49 ++++ .../GetPublishedRevisionByEntryIdUseCase.ts | 37 +++ .../abstractions.ts | 55 +++++ .../GetPublishedRevisionByEntryId/feature.ts | 21 ++ .../GetPublishedRevisionByEntryId/index.ts | 1 + .../GetUniqueFieldValuesRepository.ts | 37 +++ .../GetUniqueFieldValuesUseCase.ts | 92 ++++++++ .../GetUniqueFieldValues/abstractions.ts | 72 ++++++ .../GetUniqueFieldValues/errors.ts | 31 +++ .../GetUniqueFieldValues/feature.ts | 11 + .../GetUniqueFieldValues/index.ts | 1 + .../UnpublishEntryRepository.ts | 45 ++++ .../UnpublishEntry/UnpublishEntryUseCase.ts | 132 +++++++++++ .../UnpublishEntry/abstractions.ts | 56 +++++ .../contentEntries/UnpublishEntry/events.ts | 85 +++++++ .../contentEntries/UnpublishEntry/feature.ts | 22 ++ .../contentEntries/UnpublishEntry/index.ts | 2 + 21 files changed, 776 insertions(+), 200 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index f3f63e7cc78..b28d12724bb 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -144,12 +144,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { accessControl }), ...createContentEntryCrud({ - context, - getIdentity, - getTenant, - getLocale, - storageOperations, - accessControl + context }), export: { ...createExportCrud(context) diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index aee26a80212..7c841ea1d57 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -1,6 +1,4 @@ -import { parseIdentifier } from "@webiny/utils"; import WebinyError from "@webiny/error"; -import { NotFoundError } from "@webiny/handler-graphql"; import type { CmsContext, CmsEntry, @@ -10,13 +8,10 @@ import type { CmsEntryMeta, CmsEntryValues, CmsModel, - CmsStorageEntry, CreateCmsEntryInput, CreateCmsEntryOptionsInput, EntryBeforeListTopicParams, - HeadlessCmsStorageOperations, OnEntryAfterCreateTopicParams, - OnEntryAfterDeleteMultipleTopicParams, OnEntryAfterDeleteTopicParams, OnEntryAfterMoveTopicParams, OnEntryAfterPublishTopicParams, @@ -25,7 +20,6 @@ import type { OnEntryAfterUnpublishTopicParams, OnEntryAfterUpdateTopicParams, OnEntryBeforeCreateTopicParams, - OnEntryBeforeDeleteMultipleTopicParams, OnEntryBeforeDeleteTopicParams, OnEntryBeforeGetTopicParams, OnEntryBeforeMoveTopicParams, @@ -37,7 +31,6 @@ import type { OnEntryCreateErrorTopicParams, OnEntryCreateRevisionErrorTopicParams, OnEntryDeleteErrorTopicParams, - OnEntryDeleteMultipleErrorTopicParams, OnEntryMoveErrorTopicParams, OnEntryPublishErrorTopicParams, OnEntryRepublishErrorTopicParams, @@ -56,20 +49,8 @@ import { createTopic } from "@webiny/pubsub"; import { assignBeforeEntryCreate } from "./contentEntry/beforeCreate.js"; import { assignBeforeEntryUpdate } from "./contentEntry/beforeUpdate.js"; import { assignAfterEntryDelete } from "./contentEntry/afterDelete.js"; -import { - createTransformEntryCallable, - entryFromStorageTransform, - entryToStorageTransform -} from "~/utils/entryStorage.js"; -import { getSearchableFields } from "./contentEntry/searchableFields.js"; -import { createUnpublishEntryData } from "./contentEntry/entryDataFactories/index.js"; -import type { AccessControl } from "./AccessControl/AccessControl.js"; -import { getPublishedRevisionByEntryIdUseCases } from "~/crud/contentEntry/useCases/index.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; import type { GenericRecord } from "@webiny/api/types.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; -import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import { CreateEntryUseCase } from "~/features/contentEntries/CreateEntry/index.js"; import { CreateEntryRevisionFromUseCase } from "~/features/contentEntries/CreateEntryRevisionFrom/abstractions.js"; import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index.js"; @@ -93,18 +74,15 @@ import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntr import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; import { DeleteMultipleEntriesUseCase } from "~/features/contentEntries/DeleteMultipleEntries/abstractions.js"; import { RestoreEntryFromBinUseCase } from "~/features/contentEntries/RestoreEntryFromBin/abstractions.js"; +import { UnpublishEntryUseCase } from "~/features/contentEntries/UnpublishEntry/index.js"; +import { GetUniqueFieldValuesUseCase } from "~/features/contentEntries/GetUniqueFieldValues/index.js"; interface CreateContentEntryCrudParams { - storageOperations: HeadlessCmsStorageOperations; - accessControl: AccessControl; context: CmsContext; - getIdentity: () => SecurityIdentity; - getTenant: () => Tenant; - getLocale: () => I18NLocale; } export const createContentEntryCrud = (params: CreateContentEntryCrudParams): CmsEntryContext => { - const { storageOperations, accessControl, context, getIdentity: getSecurityIdentity } = params; + const { context } = params; /** * Create @@ -214,18 +192,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm const onEntryRevisionDeleteError = createTopic( "cms.onEntryRevisionDeleteError" ); - /** - * Delete multiple entries - */ - const onEntryBeforeDeleteMultiple = createTopic( - "cms.onEntryBeforeDeleteMultiple" - ); - const onEntryAfterDeleteMultiple = createTopic( - "cms.onEntryAfterDeleteMultiple" - ); - const onEntryDeleteMultipleError = createTopic( - "cms.onEntryDeleteMultipleError" - ); /** * Get entry @@ -239,6 +205,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm /** * We need to assign some default behaviors. + * TODO: move this to a separate feature with multiple event handlers and field locking. */ assignBeforeEntryCreate({ context, @@ -253,18 +220,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm onEntryAfterDelete }); - const transformEntryFromStorageCallable = createTransformEntryCallable({ - context - }); - - /** - * Get published revision by entryId - */ - const { getPublishedRevisionByEntryIdUseCase } = getPublishedRevisionByEntryIdUseCases({ - transform: transformEntryFromStorageCallable, - operation: storageOperations.entries.getPublishedRevisionByEntryId - }); - const createEntry: CmsEntryContext["createEntry"] = async ( model: CmsModel, rawInput: CreateCmsEntryInput, @@ -459,158 +414,31 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm model: CmsModel, id: string ) => { - await accessControl.ensureCanAccessEntry({ model, pw: "u" }); - - const { id: entryId } = parseIdentifier(id); - - const originalStorageEntry = await getPublishedRevisionByEntryIdUseCase.execute(model, { - id: entryId - }); - - if (!originalStorageEntry) { - const error = new NotFoundError( - `Entry "${id}" of model "${model.modelId}" was not found.` - ); - // @ts-expect-error Temporary migration fix - error.code = "ENTRY_NOT_FOUND"; - throw error; - } - - if (originalStorageEntry.id !== id) { - throw new WebinyError(`Entry is not published.`, "UNPUBLISH_ERROR", { - entry: originalStorageEntry - }); - } - - const originalEntry = await entryFromStorageTransform(context, model, originalStorageEntry); - - await accessControl.ensureCanAccessEntry({ model, entry: originalEntry, pw: "u" }); - - const { entry } = await createUnpublishEntryData({ - context, - model, - originalEntry, - getIdentity: getSecurityIdentity - }); - - let storageEntry: CmsStorageEntry | null = null; - - try { - await onEntryBeforeUnpublish.publish({ - entry, - model - }); - - storageEntry = await entryToStorageTransform(context, model, entry); - - const result = await storageOperations.entries.unpublish(model, { - entry, - storageEntry - }); - - await onEntryAfterUnpublish.publish({ - entry, - storageEntry: result, - model - }); + // Delegate to new UnpublishEntry use case + const useCase = context.container.resolve(UnpublishEntryUseCase); + const result = await useCase.execute(model, id); - return entry; - } catch (ex) { - await onEntryUnpublishError.publish({ - entry, - model, - error: ex - }); + if (result.isFail()) { + const error = result.error; throw new WebinyError( - ex.message || "Could not unpublish entry.", - ex.code || "UNPUBLISH_ERROR", - { - originalEntry, - originalStorageEntry, - entry, - storageEntry - } + error.message || "Could not unpublish entry.", + error.code || "UNPUBLISH_ERROR", + error.data ); } + + return result.value as CmsEntry; }; const getUniqueFieldValues: CmsEntryContext["getUniqueFieldValues"] = async (model, params) => { - await accessControl.ensureCanAccessEntry({ model }); - - const { where: initialWhere, fieldId } = params; - - const where = { - ...initialWhere - }; - /** - * Possibly only get records which are owned by current user. - * Or if searching for the owner set that value - in the case that user can see other entries than their own. - */ - if (await accessControl.canAccessOnlyOwnedEntries({ model })) { - where.createdBy = getSecurityIdentity().id; - } - - /** - * Where must contain either latest or published keys. - * We cannot list entries without one of those - */ - if (where.latest && where.published) { - throw new WebinyError( - "Cannot list entries that are both published and latest.", - "LIST_ENTRIES_ERROR", - { - where - } - ); - } else if (!where.latest && !where.published) { - throw new WebinyError( - "Cannot list entries if we do not have latest or published defined.", - "LIST_ENTRIES_ERROR", - { - where - } - ); - } - /** - * We need to verify that the field in question is searchable. - */ - const fields = getSearchableFields({ - fields: model.fields, - plugins: context.plugins, - input: [] - }); + const useCase = context.container.resolve(GetUniqueFieldValuesUseCase); + const result = await useCase.execute(model, params); - if (!fields.includes(fieldId)) { - throw new WebinyError( - "Cannot list unique entry field values if the field is not searchable.", - "LIST_UNIQUE_ENTRY_VALUES_ERROR", - { - fieldId - } - ); + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } - try { - return await storageOperations.entries.getUniqueFieldValues(model, { - where, - fieldId - }); - } catch (ex) { - throw new WebinyError( - "Error while fetching unique entry values from storage.", - "LIST_UNIQUE_ENTRY_VALUES_ERROR", - { - error: { - message: ex.message, - code: ex.code, - data: ex.data - }, - model, - where, - fieldId - } - ); - } + return result.value; }; const getEntryTraverser = async (modelId: string) => { diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts index 38ce1cb4153..7997586f8f5 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUnpublishEntryData.ts @@ -1,12 +1,10 @@ -import type { CmsContext, CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; +import type { CmsEntry, CmsEntryValues } from "~/types/index.js"; import { STATUS_UNPUBLISHED } from "./statuses.js"; import { getIdentity } from "~/utils/identity.js"; import { getDate } from "~/utils/date.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; type CreateRepublishEntryDataParams = { - model: CmsModel; - context: CmsContext; getIdentity: () => SecurityIdentity; originalEntry: CmsEntry; }; diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts index 63dcfffc8e4..9c95d527725 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts @@ -20,6 +20,9 @@ import { DeleteEntryRevisionFeature } from "./DeleteEntryRevision/feature.js"; import { DeleteMultipleEntriesFeature } from "./DeleteMultipleEntries/feature.js"; import { RestoreEntryFromBinFeature } from "./RestoreEntryFromBin/feature.js"; import { GetLatestRevisionByEntryIdFeature } from "./GetLatestRevisionByEntryId/feature.js"; +import { GetPublishedRevisionByEntryIdFeature } from "./GetPublishedRevisionByEntryId/feature.js"; +import { UnpublishEntryFeature } from "./UnpublishEntry/feature.js"; +import { GetUniqueFieldValuesFeature } from "./GetUniqueFieldValues/feature.js"; export const ContentEntriesFeature = createFeature({ name: "ContentEntries", @@ -29,12 +32,14 @@ export const ContentEntriesFeature = createFeature({ GetEntriesByIdsFeature.register(container); GetEntryByIdFeature.register(container); GetPublishedEntriesByIdsFeature.register(container); + GetPublishedRevisionByEntryIdFeature.register(container); GetLatestEntriesByIdsFeature.register(container); GetLatestRevisionByEntryIdFeature.register(container); GetRevisionsByEntryIdFeature.register(container); GetPreviousRevisionByEntryIdFeature.register(container); GetEntryFeature.register(container); ListEntriesFeature.register(container); + GetUniqueFieldValuesFeature.register(container); // Command features CreateEntryFeature.register(container); @@ -43,6 +48,7 @@ export const ContentEntriesFeature = createFeature({ ValidateEntryFeature.register(container); MoveEntryFeature.register(container); PublishEntryFeature.register(container); + UnpublishEntryFeature.register(container); RepublishEntryFeature.register(container); DeleteEntryFeature.register(container); DeleteEntryRevisionFeature.register(container); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts new file mode 100644 index 00000000000..0122ac18fb0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts @@ -0,0 +1,49 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPublishedRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; + +/** + * GetPublishedRevisionByEntryIdRepository - Fetches published revision from storage. + * Returns null if entry not found or is deleted. + */ +class GetPublishedRevisionByEntryIdRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryFromStorageTransform: EntryFromStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entryId: string + ): Promise> { + try { + // Get published revision from storage + const storageEntry = await this.storageOperations.entries.getPublishedRevisionByEntryId( + model, + { id: entryId } + ); + + if (!storageEntry || storageEntry.wbyDeleted) { + return Result.fail(new EntryNotFoundError(entryId)); + } + + // Transform storage entry to domain entry + const entry = await this.entryFromStorageTransform(model, storageEntry); + + return Result.ok(entry); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetPublishedRevisionByEntryIdRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetPublishedRevisionByEntryIdRepositoryImpl, + dependencies: [EntryFromStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts new file mode 100644 index 00000000000..3801da035c5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts @@ -0,0 +1,37 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetPublishedRevisionByEntryIdUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetPublishedRevisionByEntryIdRepository } from "./abstractions.js"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * GetPublishedRevisionByEntryIdUseCase - Orchestrates fetching published revision by entry ID. + * + * Responsibilities: + * - Delegate to repository for data fetching + * - Return null if entry not found or deleted + */ +class GetPublishedRevisionByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private repository: GetPublishedRevisionByEntryIdRepository.Interface) {} + + async execute( + model: CmsModel, + entryId: string + ): Promise> { + // Delegate to repository + const result = await this.repository.execute(model, entryId); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetPublishedRevisionByEntryIdUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetPublishedRevisionByEntryIdUseCaseImpl, + dependencies: [GetPublishedRevisionByEntryIdRepository] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts new file mode 100644 index 00000000000..a6d072ac6be --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsEntryStorageOperationsGetPublishedRevisionParams } from "~/types/index.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; + +/** + * GetPublishedRevisionByEntryId Use Case + */ +export interface IGetPublishedRevisionByEntryIdUseCase { + execute(model: CmsModel, entryId: string): Promise>; +} + +export interface IGetPublishedRevisionByEntryIdUseCaseErrors { + repository: RepositoryError; +} + +type UseCaseError = + IGetPublishedRevisionByEntryIdUseCaseErrors[keyof IGetPublishedRevisionByEntryIdUseCaseErrors]; + +export const GetPublishedRevisionByEntryIdUseCase = + createAbstraction( + "GetPublishedRevisionByEntryIdUseCase" + ); + +export namespace GetPublishedRevisionByEntryIdUseCase { + export type Interface = IGetPublishedRevisionByEntryIdUseCase; + export type Error = UseCaseError; +} + +/** + * GetPublishedRevisionByEntryIdRepository - Fetches published revision from storage. + */ +export interface IGetPublishedRevisionByEntryIdRepository { + execute(model: CmsModel, entryId: string): Promise>; +} + +export interface IGetPublishedRevisionByEntryIdRepositoryErrors { + storage: EntryStorageError; + notFound: EntryNotFoundError; +} + +type RepositoryError = + IGetPublishedRevisionByEntryIdRepositoryErrors[keyof IGetPublishedRevisionByEntryIdRepositoryErrors]; + +export const GetPublishedRevisionByEntryIdRepository = + createAbstraction( + "GetPublishedRevisionByEntryIdRepository" + ); + +export namespace GetPublishedRevisionByEntryIdRepository { + export type Interface = IGetPublishedRevisionByEntryIdRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts new file mode 100644 index 00000000000..73f95cbdd18 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts @@ -0,0 +1,21 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetPublishedRevisionByEntryIdUseCase } from "./GetPublishedRevisionByEntryIdUseCase.js"; +import { GetPublishedRevisionByEntryIdRepository } from "./GetPublishedRevisionByEntryIdRepository.js"; + +/** + * GetPublishedRevisionByEntryId Feature + * + * Provides complete functionality for fetching published revision by entry ID: + * - Use case for orchestration + * - Repository for data fetching + */ +export const GetPublishedRevisionByEntryIdFeature = createFeature({ + name: "GetPublishedRevisionByEntryId", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(GetPublishedRevisionByEntryIdUseCase); + + // Register repository in singleton scope (shared instance) + container.register(GetPublishedRevisionByEntryIdRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts new file mode 100644 index 00000000000..82fb6261b9b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts @@ -0,0 +1 @@ +export { GetPublishedRevisionByEntryIdUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts new file mode 100644 index 00000000000..d3656bd8121 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts @@ -0,0 +1,37 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { + GetUniqueFieldValuesRepository as RepositoryAbstraction, + GetUniqueFieldValuesParams +} from "./abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; + +class GetUniqueFieldValuesRepositoryImpl implements RepositoryAbstraction.Interface { + constructor(private storageOperations: StorageOperations.Interface) {} + + async execute( + model: CmsModel, + params: GetUniqueFieldValuesParams + ): Promise> { + const { where, fieldId } = params; + + try { + const values = await this.storageOperations.entries.getUniqueFieldValues(model, { + where, + fieldId + }); + + return Result.ok(values); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const GetUniqueFieldValuesRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetUniqueFieldValuesRepositoryImpl, + dependencies: [StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts new file mode 100644 index 00000000000..07bfb27991a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts @@ -0,0 +1,92 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { + GetUniqueFieldValuesUseCase as UseCaseAbstraction, + GetUniqueFieldValuesRepository, + GetUniqueFieldValuesParams +} from "./abstractions.js"; +import { AccessControl, CmsContext } from "~/features/shared/abstractions.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; +import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; +import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; + +class GetUniqueFieldValuesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetUniqueFieldValuesRepository.Interface, + private accessControl: AccessControl.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + params: GetUniqueFieldValuesParams + ): Promise> { + // Check access control - throws if not authorized + try { + await this.accessControl.ensureCanAccessEntry({ model }); + } catch (error) { + if (error instanceof NotAuthorizedError) { + return Result.fail(error); + } + throw error; + } + + const { where: initialWhere, fieldId } = params; + + const where = { + ...initialWhere + }; + + // Apply ownership filter if needed + const canAccessOnlyOwned = await this.accessControl.canAccessOnlyOwnedEntries({ model }); + if (canAccessOnlyOwned) { + const identity = this.cmsContext.security.getIdentity(); + where.createdBy = identity.id; + } + + // Validate where conditions + if (where.latest && where.published) { + return Result.fail( + new InvalidWhereConditionError( + "Cannot list entries that are both published and latest.", + where + ) + ); + } + + if (!where.latest && !where.published) { + return Result.fail( + new InvalidWhereConditionError( + "Cannot list entries if we do not have latest or published defined.", + where + ) + ); + } + + // Verify the field is searchable + const searchableFields = getSearchableFields({ + fields: model.fields, + plugins: this.cmsContext.plugins, + input: [] + }); + + if (!searchableFields.includes(fieldId)) { + return Result.fail(new FieldNotSearchableError(fieldId)); + } + + // Execute repository call + const result = await this.repository.execute(model, { where, fieldId }); + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetUniqueFieldValuesUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetUniqueFieldValuesUseCaseImpl, + dependencies: [GetUniqueFieldValuesRepository, AccessControl, CmsContext] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts new file mode 100644 index 00000000000..46ed54f9fe0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts @@ -0,0 +1,72 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; + +export interface GetUniqueFieldValuesParams { + where: { + latest?: boolean; + published?: boolean; + createdBy?: string; + [key: string]: any; + }; + fieldId: string; +} + +/** + * GetUniqueFieldValues Use Case - Fetches unique values for a specific field. + * Used for filtering/autocomplete in the UI. + */ +export interface IGetUniqueFieldValuesUseCase { + execute( + model: CmsModel, + params: GetUniqueFieldValuesParams + ): Promise>; +} + +export interface IGetUniqueFieldValuesUseCaseErrors { + notAuthorized: NotAuthorizedError; + storage: EntryStorageError; + fieldNotSearchable: FieldNotSearchableError; + invalidWhere: InvalidWhereConditionError; +} + +type UseCaseError = + IGetUniqueFieldValuesUseCaseErrors[keyof IGetUniqueFieldValuesUseCaseErrors]; + +export const GetUniqueFieldValuesUseCase = createAbstraction( + "GetUniqueFieldValuesUseCase" +); + +export namespace GetUniqueFieldValuesUseCase { + export type Interface = IGetUniqueFieldValuesUseCase; + export type Error = UseCaseError; +} + +/** + * GetUniqueFieldValuesRepository - Fetches unique field values from storage. + */ +export interface IGetUniqueFieldValuesRepository { + execute( + model: CmsModel, + params: GetUniqueFieldValuesParams + ): Promise>; +} + +export interface IGetUniqueFieldValuesRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = + IGetUniqueFieldValuesRepositoryErrors[keyof IGetUniqueFieldValuesRepositoryErrors]; + +export const GetUniqueFieldValuesRepository = createAbstraction( + "GetUniqueFieldValuesRepository" +); + +export namespace GetUniqueFieldValuesRepository { + export type Interface = IGetUniqueFieldValuesRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts new file mode 100644 index 00000000000..07a60d79c81 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts @@ -0,0 +1,31 @@ +import { BaseError } from "@webiny/feature/api"; + +type FieldNotSearchableErrorData = { + fieldId: string; +}; + +export class FieldNotSearchableError extends BaseError { + override readonly code = "FIELD_NOT_SEARCHABLE" as const; + + constructor(fieldId: string) { + super({ + message: `Cannot list unique entry field values if the field "${fieldId}" is not searchable.`, + data: { fieldId } + }); + } +} + +type InvalidWhereConditionErrorData = { + where: Record; +}; + +export class InvalidWhereConditionError extends BaseError { + override readonly code = "INVALID_WHERE_CONDITION" as const; + + constructor(message: string, where: Record) { + super({ + message, + data: { where } + }); + } +} diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts new file mode 100644 index 00000000000..7af3ee1c623 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetUniqueFieldValuesUseCase } from "./GetUniqueFieldValuesUseCase.js"; +import { GetUniqueFieldValuesRepository } from "./GetUniqueFieldValuesRepository.js"; + +export const GetUniqueFieldValuesFeature = createFeature({ + name: "GetUniqueFieldValues", + register(container) { + container.register(GetUniqueFieldValuesUseCase); + container.register(GetUniqueFieldValuesRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts new file mode 100644 index 00000000000..c61e9a31c7f --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts @@ -0,0 +1 @@ +export { GetUniqueFieldValuesUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts new file mode 100644 index 00000000000..dc7db9b78e9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { UnpublishEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { EntryToStorageTransform } from "~/legacy/abstractions.js"; + +/** + * UnpublishEntryRepository - Handles persistence of entry unpublish. + * Transforms domain entry to storage format and persists unpublish operation. + */ +class UnpublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private entryToStorageTransform: EntryToStorageTransform.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute( + model: CmsModel, + entry: CmsEntry + ): Promise> { + try { + // Transform domain entry to storage format + const storageEntry = await this.entryToStorageTransform(model, entry); + + // Persist unpublish to storage + const result = await this.storageOperations.entries.unpublish(model, { + entry, + storageEntry + }); + + return Result.ok(result); + } catch (error) { + return Result.fail(new EntryStorageError(error as Error)); + } + } +} + +export const UnpublishEntryRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: UnpublishEntryRepositoryImpl, + dependencies: [EntryToStorageTransform, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts new file mode 100644 index 00000000000..a36c0e18aef --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts @@ -0,0 +1,132 @@ +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { parseIdentifier } from "@webiny/utils"; +import { UnpublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { UnpublishEntryRepository } from "./abstractions.js"; +import { EntryBeforeUnpublishEvent } from "./events.js"; +import { EntryAfterUnpublishEvent } from "./events.js"; +import { EntryUnpublishErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { GetPublishedRevisionByEntryIdUseCase } from "~/features/contentEntries/GetPublishedRevisionByEntryId/index.js"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryValidationError } from "~/domains/contentEntries/errors.js"; +import { createUnpublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; + +/** + * UnpublishEntryUseCase - Orchestrates entry unpublishing. + * + * Responsibilities: + * - Fetch published revision by entry ID + * - Validate entry is published (matches requested ID) + * - Transform to unpublish data + * - Apply access control + * - Publish domain events + * - Delegate persistence to repository + */ +class UnpublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: UnpublishEntryRepository.Interface, + private accessControl: AccessControl.Interface, + private identityContext: IdentityContext.Interface, + private getPublishedRevisionByEntryId: GetPublishedRevisionByEntryIdUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise> { + // Check initial access control + const canAccess = await this.accessControl.canAccessEntry({ model, pw: "u" }); + if (!canAccess) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Parse entry ID from revision ID + const { id: entryId } = parseIdentifier(id); + + // Get published revision + const publishedResult = await this.getPublishedRevisionByEntryId.execute(model, entryId); + + if (publishedResult.isFail()) { + return Result.fail(publishedResult.error); + } + + const originalEntry = publishedResult.value; + + if (!originalEntry) { + return Result.fail(new EntryNotFoundError(id)); + } + + // Validate that the entry is actually published (published revision ID must match requested ID) + if (originalEntry.id !== id) { + return Result.fail(new EntryValidationError(`Entry is not published!`)); + } + + // Apply access control on the specific entry + const canAccessEntry = await this.accessControl.canAccessEntry({ + model, + entry: originalEntry, + pw: "u" + }); + + if (!canAccessEntry) { + return Result.fail(NotAuthorizedError.fromModel(model)); + } + + // Transform to unpublish data + const { entry } = await createUnpublishEntryData({ + originalEntry, + getIdentity: () => this.identityContext.getIdentity() + }); + + try { + // Publish before event + await this.eventPublisher.publish(new EntryBeforeUnpublishEvent({ entry, model })); + + // Persist unpublish + const unpublishResult = await this.repository.execute(model, entry); + if (unpublishResult.isFail()) { + await this.eventPublisher.publish( + new EntryUnpublishErrorEvent({ entry, model, error: unpublishResult.error }) + ); + return Result.fail(unpublishResult.error); + } + + const storageEntry = unpublishResult.value; + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterUnpublishEvent({ + entry, + storageEntry, + model + }) + ); + + return Result.ok(entry); + } catch (error) { + await this.eventPublisher.publish( + new EntryUnpublishErrorEvent({ entry, model, error: error as Error }) + ); + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const UnpublishEntryUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: UnpublishEntryUseCaseImpl, + dependencies: [ + EventPublisher, + UnpublishEntryRepository, + AccessControl, + IdentityContext, + GetPublishedRevisionByEntryIdUseCase + ] +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts new file mode 100644 index 00000000000..fd4272e3aea --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts @@ -0,0 +1,56 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * UnpublishEntry Use Case + */ +export interface IUnpublishEntryUseCase { + execute(model: CmsModel, id: string): Promise>; +} + +export interface IUnpublishEntryUseCaseErrors { + notAuthorized: NotAuthorizedError; + notFound: EntryNotFoundError; + validation: EntryValidationError; + repository: RepositoryError; +} + +type UseCaseError = IUnpublishEntryUseCaseErrors[keyof IUnpublishEntryUseCaseErrors]; + +export const UnpublishEntryUseCase = createAbstraction( + "UnpublishEntryUseCase" +); + +export namespace UnpublishEntryUseCase { + export type Interface = IUnpublishEntryUseCase; + export type Error = UseCaseError; +} + +/** + * UnpublishEntryRepository - Persists entry unpublish to storage. + * Takes a domain CmsEntry object and unpublishes it. + */ +export interface IUnpublishEntryRepository { + execute(model: CmsModel, entry: CmsEntry): Promise>; +} + +export interface IUnpublishEntryRepositoryErrors { + storage: EntryStorageError; +} + +type RepositoryError = IUnpublishEntryRepositoryErrors[keyof IUnpublishEntryRepositoryErrors]; + +export const UnpublishEntryRepository = createAbstraction( + "UnpublishEntryRepository" +); + +export namespace UnpublishEntryRepository { + export type Interface = IUnpublishEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts new file mode 100644 index 00000000000..8711a665c6f --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts @@ -0,0 +1,85 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface EntryBeforeUnpublishPayload { + entry: CmsEntry; + model: CmsModel; +} + +export interface EntryAfterUnpublishPayload { + entry: CmsEntry; + storageEntry: any; + model: CmsModel; +} + +export interface EntryUnpublishErrorPayload { + entry: CmsEntry; + model: CmsModel; + error: Error; +} + +/** + * EntryBeforeUnpublishEvent - Published before unpublishing an entry + */ +export class EntryBeforeUnpublishEvent extends DomainEvent { + eventType = "entry.beforeUnpublish" as const; + + getHandlerAbstraction() { + return EntryBeforeUnpublishHandler; + } +} + +export const EntryBeforeUnpublishHandler = createAbstraction< + IEventHandler +>("EntryBeforeUnpublishHandler"); + +export namespace EntryBeforeUnpublishHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeUnpublishEvent; +} + +/** + * EntryAfterUnpublishEvent - Published after unpublishing an entry + */ +export class EntryAfterUnpublishEvent extends DomainEvent { + eventType = "entry.afterUnpublish" as const; + + getHandlerAbstraction() { + return EntryAfterUnpublishHandler; + } +} + +export const EntryAfterUnpublishHandler = createAbstraction< + IEventHandler +>("EntryAfterUnpublishHandler"); + +export namespace EntryAfterUnpublishHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterUnpublishEvent; +} + +/** + * EntryUnpublishErrorEvent - Published when unpublish fails + */ +export class EntryUnpublishErrorEvent extends DomainEvent { + eventType = "entry.unpublishError" as const; + + getHandlerAbstraction() { + return EntryUnpublishErrorHandler; + } +} + +export const EntryUnpublishErrorHandler = createAbstraction< + IEventHandler +>("EntryUnpublishErrorHandler"); + +export namespace EntryUnpublishErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryUnpublishErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts new file mode 100644 index 00000000000..c4c5ea99ea7 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts @@ -0,0 +1,22 @@ +import { createFeature } from "@webiny/feature/api"; +import { UnpublishEntryUseCase } from "./UnpublishEntryUseCase.js"; +import { UnpublishEntryRepository } from "./UnpublishEntryRepository.js"; + +/** + * UnpublishEntry Feature + * + * Provides complete functionality for unpublishing content entries: + * - Use case for orchestration + * - Repository for persistence + * - Events for extensibility + */ +export const UnpublishEntryFeature = createFeature({ + name: "UnpublishEntry", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(UnpublishEntryUseCase); + + // Register repository in singleton scope (shared instance) + container.register(UnpublishEntryRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts new file mode 100644 index 00000000000..8c0416d928c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts @@ -0,0 +1,2 @@ +export { UnpublishEntryUseCase } from "./abstractions.js"; +export * from "./events.js"; From 2dc32608c136c9b9b977ac1fab774cd70927211e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 22:25:19 +0100 Subject: [PATCH 09/71] wip: delete unused files --- .../src/crud/contentEntry.crud.ts | 6 +- .../useCases/DeleteEntry/DeleteEntry.ts | 57 -------- .../DeleteEntry/DeleteEntryOperation.ts | 18 --- .../DeleteEntryOperationWithEvents.ts | 47 ------ .../useCases/DeleteEntry/DeleteEntrySecure.ts | 18 --- .../useCases/DeleteEntry/MoveEntryToBin.ts | 36 ----- .../DeleteEntry/MoveEntryToBinOperation.ts | 18 --- .../MoveEntryToBinOperationWithEvents.ts | 47 ------ .../DeleteEntry/TransformEntryDelete.ts | 25 ---- .../DeleteEntry/TransformEntryMoveToBin.ts | 66 --------- .../useCases/DeleteEntry/index.ts | 77 ---------- .../GetEntriesByIds/GetEntriesByIds.ts | 30 ---- .../GetEntriesByIdsNotDeleted.ts | 15 -- .../GetEntriesByIds/GetEntriesByIdsSecure.ts | 24 ---- .../useCases/GetEntriesByIds/index.ts | 22 --- .../GetLatestEntriesByIds.ts | 30 ---- .../GetLatestEntriesByIdsNotDeleted.ts | 15 -- .../GetLatestEntriesByIdsSecure.ts | 24 ---- .../useCases/GetLatestEntriesByIds/index.ts | 27 ---- .../GetLatestRevisionByEntryId.ts | 32 ----- .../GetLatestRevisionByEntryIdDeleted.ts | 20 --- .../GetLatestRevisionByEntryIdNotDeleted.ts | 20 --- .../GetLatestRevisionByEntryId/index.ts | 31 ---- .../GetPreviousRevisionByEntryId.ts | 32 ----- .../GetPreviousRevisionByEntryIdNotDeleted.ts | 23 --- .../GetPreviousRevisionByEntryId/index.ts | 25 ---- .../GetPublishedEntriesByIds.ts | 33 ----- .../GetPublishedEntriesByIdsNotDeleted.ts | 15 -- .../GetPublishedEntriesByIdsSecure.ts | 24 ---- .../GetPublishedEntriesByIds/index.ts | 32 ----- .../GetPublishedRevisionByEntryId.ts | 29 ---- ...GetPublishedRevisionByEntryIdNotDeleted.ts | 23 --- .../GetPublishedRevisionByEntryId/index.ts | 25 ---- .../GetRevisionById/GetRevisionById.ts | 28 ---- .../GetRevisionByIdNotDeleted.ts | 20 --- .../useCases/GetRevisionById/index.ts | 18 --- .../GetRevisionsByEntryId.ts | 30 ---- .../GetRevisionsByEntryIdNotDeleted.ts | 15 -- .../useCases/GetRevisionsByEntryId/index.ts | 22 --- .../useCases/ListEntries/GetEntry.ts | 28 ---- .../useCases/ListEntries/GetEntrySecure.ts | 39 ----- .../useCases/ListEntries/ListEntries.ts | 66 --------- .../ListEntries/ListEntriesOperation.ts | 32 ----- .../ListEntriesOperationDeleted.ts | 19 --- .../ListEntries/ListEntriesOperationLatest.ts | 20 --- .../ListEntriesOperationNotDeleted.ts | 19 --- .../ListEntriesOperationPublished.ts | 19 --- .../ListEntriesOperationWithEvents.ts | 22 --- ...istEntriesOperationWithSearchableFields.ts | 23 --- .../ListEntriesOperationWithSort.ts | 29 ---- .../ListEntriesOperationWithStatusCheck.ts | 39 ----- .../useCases/ListEntries/ListEntriesSecure.ts | 48 ------- .../useCases/ListEntries/index.ts | 111 --------------- .../RestoreEntryFromBin.ts | 38 ----- .../RestoreEntryFromBinOperation.ts | 26 ---- .../RestoreEntryFromBinOperationWithEvents.ts | 51 ------- .../RestoreEntryFromBinSecure.ts | 18 --- .../TransformEntryRestoreFromBin.ts | 65 --------- .../useCases/RestoreEntryFromBin/index.ts | 55 ------- .../src/crud/contentEntry/useCases/index.ts | 11 -- .../contentEntries/shared/abstractions.ts | 134 ------------------ 61 files changed, 1 insertion(+), 2010 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntry.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperationWithEvents.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntrySecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBin.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperationWithEvents.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryDelete.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryMoveToBin.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsSecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsSecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsSecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionById.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionByIdNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryIdNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntry.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntrySecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntries.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationLatest.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationNotDeleted.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationPublished.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithEvents.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSearchableFields.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSort.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithStatusCheck.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesSecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBin.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperationWithEvents.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinSecure.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/TransformEntryRestoreFromBin.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/index.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/useCases/index.ts delete mode 100644 packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 7c841ea1d57..fb922d62159 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -536,11 +536,7 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm throw new WebinyError( error.message || `Entry by ID "${id}" not found.`, error.code || "GET_ENTRY_BY_ID_ERROR", - { - error, - id, - model - } + error.data ); } diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntry.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntry.ts deleted file mode 100644 index 8ceb1029092..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { NotFoundError } from "@webiny/handler-graphql"; -import { parseIdentifier } from "@webiny/utils"; -import type { - IDeleteEntry, - IDeleteEntryOperation, - IGetLatestRevisionByEntryId -} from "../../abstractions/index.js"; -import type { CmsDeleteEntryOptions, CmsEntry, CmsModel } from "~/types/index.js"; -import type { TransformEntryDelete } from "./TransformEntryDelete.js"; - -export class DeleteEntry implements IDeleteEntry { - private getEntry: IGetLatestRevisionByEntryId; - private transformEntry: TransformEntryDelete; - private deleteEntry: IDeleteEntryOperation; - - constructor( - getEntry: IGetLatestRevisionByEntryId, - transformEntry: TransformEntryDelete, - deleteEntry: IDeleteEntryOperation - ) { - this.getEntry = getEntry; - this.transformEntry = transformEntry; - this.deleteEntry = deleteEntry; - } - - async execute(model: CmsModel, id: string, options: CmsDeleteEntryOptions) { - const { force } = options; - - const entryToDelete = await this.getEntry.execute(model, { id }); - - /** - * In the case we are forcing the deletion, we do not need the storageEntry to exist as it might be an error when loading single database record. - * - * This happens, sometimes, in the Elasticsearch system as the entry might get deleted from the DynamoDB but not from the Elasticsearch. - * This is due to high load on the Elasticsearch at the time of the deletion. - */ - if (!entryToDelete && force) { - const { id: entryId } = parseIdentifier(id); - const entry = { - id, - entryId - } as CmsEntry; - return await this.deleteEntry.execute(model, { entry }); - } - - /** - * If there is no entry, and we do not force the deletion, just throw an error. - */ - if (!entryToDelete) { - throw new NotFoundError(`Entry "${id}" was not found!`); - } - - const { entry } = await this.transformEntry.execute(model, entryToDelete); - - await this.deleteEntry.execute(model, { entry }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperation.ts deleted file mode 100644 index 8a8b19e01cb..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { IDeleteEntryOperation } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsDeleteParams, - CmsModel -} from "~/types/index.js"; - -export class DeleteEntryOperation implements IDeleteEntryOperation { - private operation: CmsEntryStorageOperations["delete"]; - - constructor(operation: CmsEntryStorageOperations["delete"]) { - this.operation = operation; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsDeleteParams) { - await this.operation(model, params); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperationWithEvents.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperationWithEvents.ts deleted file mode 100644 index ccb26151adc..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntryOperationWithEvents.ts +++ /dev/null @@ -1,47 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { IDeleteEntryOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsDeleteParams, CmsModel } from "~/types/index.js"; -import type { DeleteEntryUseCasesTopics } from "./index.js"; - -export class DeleteEntryOperationWithEvents implements IDeleteEntryOperation { - private topics: DeleteEntryUseCasesTopics; - private operation: IDeleteEntryOperation; - - constructor(topics: DeleteEntryUseCasesTopics, operation: IDeleteEntryOperation) { - this.topics = topics; - this.operation = operation; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsDeleteParams) { - const { entry } = params; - try { - await this.topics.onEntryBeforeDelete.publish({ - entry, - model, - permanent: true - }); - - await this.operation.execute(model, params); - - await this.topics.onEntryAfterDelete.publish({ - entry, - model, - permanent: true - }); - } catch (ex) { - await this.topics.onEntryDeleteError.publish({ - entry, - model, - permanent: true, - error: ex - }); - throw new WebinyError( - ex.message || "Could not delete entry.", - ex.code || "DELETE_ERROR", - { - entry - } - ); - } - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntrySecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntrySecure.ts deleted file mode 100644 index 0e20cdbaeb6..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/DeleteEntrySecure.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { IDeleteEntry } from "~/crud/contentEntry/abstractions/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; - -export class DeleteEntrySecure implements IDeleteEntry { - private accessControl: AccessControl; - private useCase: IDeleteEntry; - - constructor(accessControl: AccessControl, useCase: IDeleteEntry) { - this.accessControl = accessControl; - this.useCase = useCase; - } - - async execute(model: CmsModel, id: string, options: CmsDeleteEntryOptions) { - await this.accessControl.ensureCanAccessEntry({ model, rwd: "d" }); - await this.useCase.execute(model, id, options); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBin.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBin.ts deleted file mode 100644 index b3fa452f5db..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBin.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NotFoundError } from "@webiny/handler-graphql"; -import type { - IDeleteEntry, - IGetLatestRevisionByEntryId, - IMoveEntryToBinOperation -} from "~/crud/contentEntry/abstractions/index.js"; -import type { TransformEntryMoveToBin } from "./TransformEntryMoveToBin.js"; -import type { CmsModel } from "~/types/index.js"; - -export class MoveEntryToBin implements IDeleteEntry { - private getEntry: IGetLatestRevisionByEntryId; - private transformEntry: TransformEntryMoveToBin; - private moveEntryToBin: IMoveEntryToBinOperation; - - constructor( - getEntry: IGetLatestRevisionByEntryId, - transformEntry: TransformEntryMoveToBin, - moveEntryToBin: IMoveEntryToBinOperation - ) { - this.getEntry = getEntry; - this.transformEntry = transformEntry; - this.moveEntryToBin = moveEntryToBin; - } - - async execute(model: CmsModel, id: string) { - const entryToDelete = await this.getEntry.execute(model, { id }); - - if (!entryToDelete) { - throw new NotFoundError(`Entry "${id}" was not found!`); - } - - const { entry, storageEntry } = await this.transformEntry.execute(model, entryToDelete); - - await this.moveEntryToBin.execute(model, { entry, storageEntry }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperation.ts deleted file mode 100644 index 597a3e2aa56..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { IMoveEntryToBinOperation } from "~/crud/contentEntry/abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsMoveToBinParams, - CmsModel -} from "~/types/index.js"; - -export class MoveEntryToBinOperation implements IMoveEntryToBinOperation { - private operation: CmsEntryStorageOperations["moveToBin"]; - - constructor(operation: CmsEntryStorageOperations["moveToBin"]) { - this.operation = operation; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsMoveToBinParams) { - await this.operation(model, params); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperationWithEvents.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperationWithEvents.ts deleted file mode 100644 index 50a180dbf29..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/MoveEntryToBinOperationWithEvents.ts +++ /dev/null @@ -1,47 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { IMoveEntryToBinOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsMoveToBinParams, CmsModel } from "~/types/index.js"; -import type { DeleteEntryUseCasesTopics } from "./index.js"; - -export class MoveEntryToBinOperationWithEvents implements IMoveEntryToBinOperation { - private topics: DeleteEntryUseCasesTopics; - private operation: IMoveEntryToBinOperation; - - constructor(topics: DeleteEntryUseCasesTopics, operation: IMoveEntryToBinOperation) { - this.topics = topics; - this.operation = operation; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsMoveToBinParams) { - const { entry } = params; - try { - await this.topics.onEntryBeforeDelete.publish({ - entry, - model, - permanent: false - }); - - await this.operation.execute(model, params); - - await this.topics.onEntryAfterDelete.publish({ - entry, - model, - permanent: false - }); - } catch (ex) { - await this.topics.onEntryDeleteError.publish({ - entry, - model, - permanent: false, - error: ex - }); - throw new WebinyError( - ex.message || "Could not delete entry.", - ex.code || "DELETE_ERROR", - { - entry - } - ); - } - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryDelete.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryDelete.ts deleted file mode 100644 index dedfbc56794..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryDelete.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { entryFromStorageTransform } from "~/utils/entryStorage.js"; -import type { - CmsContext, - CmsEntry, - CmsEntryStorageOperationsDeleteParams, - CmsModel -} from "~/types/index.js"; - -export class TransformEntryDelete { - private context: CmsContext; - - constructor(context: CmsContext) { - this.context = context; - } - async execute( - model: CmsModel, - initialEntry: CmsEntry - ): Promise { - const entry = await entryFromStorageTransform(this.context, model, initialEntry); - - return { - entry - }; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryMoveToBin.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryMoveToBin.ts deleted file mode 100644 index 6c69afb6ad3..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/TransformEntryMoveToBin.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; -import { getDate } from "~/utils/date.js"; -import { getIdentity } from "~/utils/identity.js"; -import type { - CmsContext, - CmsEntry, - CmsEntryStorageOperationsMoveToBinParams, - CmsModel -} from "~/types/index.js"; -import { ROOT_FOLDER } from "~/constants.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export class TransformEntryMoveToBin { - private context: CmsContext; - private getIdentity: () => SecurityIdentity; - - constructor(context: CmsContext, getIdentity: () => SecurityIdentity) { - this.context = context; - this.getIdentity = getIdentity; - } - async execute( - model: CmsModel, - initialEntry: CmsEntry - ): Promise { - const originalEntry = await entryFromStorageTransform(this.context, model, initialEntry); - const entry = await this.createDeleteEntryData(model, originalEntry); - const storageEntry = await entryToStorageTransform(this.context, model, entry); - - return { - entry, - storageEntry - }; - } - - private async createDeleteEntryData(model: CmsModel, originalEntry: CmsEntry) { - const currentDateTime = new Date().toISOString(); - const currentIdentity = this.getIdentity(); - - const entry: CmsEntry = { - ...originalEntry, - wbyDeleted: true, - - /** - * Entry location fields. 👇 - */ - location: { - folderId: ROOT_FOLDER - }, - binOriginalFolderId: originalEntry.location?.folderId, - - /** - * Entry-level meta fields. 👇 - */ - deletedOn: getDate(currentDateTime, null), - deletedBy: getIdentity(currentIdentity, null), - - /** - * Revision-level meta fields. 👇 - */ - revisionDeletedOn: getDate(currentDateTime, null), - revisionDeletedBy: getIdentity(currentIdentity, null) - }; - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/index.ts deleted file mode 100644 index 16efd4bce02..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/DeleteEntry/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { DeleteEntry } from "./DeleteEntry.js"; -import { DeleteEntryOperation } from "./DeleteEntryOperation.js"; -import { DeleteEntryOperationWithEvents } from "./DeleteEntryOperationWithEvents.js"; -import { DeleteEntrySecure } from "./DeleteEntrySecure.js"; -import { MoveEntryToBin } from "./MoveEntryToBin.js"; -import { MoveEntryToBinOperation } from "./MoveEntryToBinOperation.js"; -import { MoveEntryToBinOperationWithEvents } from "./MoveEntryToBinOperationWithEvents.js"; -import { TransformEntryDelete } from "./TransformEntryDelete.js"; -import { TransformEntryMoveToBin } from "./TransformEntryMoveToBin.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import type { - CmsContext, - CmsEntryStorageOperations, - OnEntryAfterDeleteTopicParams, - OnEntryBeforeDeleteTopicParams, - OnEntryDeleteErrorTopicParams -} from "~/types/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { IGetLatestRevisionByEntryId } from "~/crud/contentEntry/abstractions/index.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export interface DeleteEntryUseCasesTopics { - onEntryBeforeDelete: Topic; - onEntryAfterDelete: Topic; - onEntryDeleteError: Topic; -} - -interface DeleteEntryUseCasesParams { - deleteOperation: CmsEntryStorageOperations["delete"]; - moveToBinOperation: CmsEntryStorageOperations["moveToBin"]; - getEntry: IGetLatestRevisionByEntryId; - getEntryWithDeleted: IGetLatestRevisionByEntryId; - accessControl: AccessControl; - topics: DeleteEntryUseCasesTopics; - context: CmsContext; - getIdentity: () => SecurityIdentity; -} - -export const deleteEntryUseCases = (params: DeleteEntryUseCasesParams) => { - /** - * Delete an entry, destroying it from the database - */ - const deleteEntryOperation = new DeleteEntryOperation(params.deleteOperation); - const deleteEntryOperationWithEvents = new DeleteEntryOperationWithEvents( - params.topics, - deleteEntryOperation - ); - const deleteTransform = new TransformEntryDelete(params.context); - const deleteEntry = new DeleteEntry( - params.getEntryWithDeleted, - deleteTransform, - deleteEntryOperationWithEvents - ); - const deleteEntrySecure = new DeleteEntrySecure(params.accessControl, deleteEntry); - - /** - * Move entry to the bin, marking it as deleted - */ - const moveEntryToBinOperation = new MoveEntryToBinOperation(params.moveToBinOperation); - const moveEntryToBinOperationWithEvents = new MoveEntryToBinOperationWithEvents( - params.topics, - moveEntryToBinOperation - ); - const moveToBinTransform = new TransformEntryMoveToBin(params.context, params.getIdentity); - const moveEntryToBin = new MoveEntryToBin( - params.getEntry, - moveToBinTransform, - moveEntryToBinOperationWithEvents - ); - const moveEntryToBinSecure = new DeleteEntrySecure(params.accessControl, moveEntryToBin); - - return { - deleteEntryUseCase: deleteEntrySecure, - moveEntryToBinUseCase: moveEntryToBinSecure, - deleteEntryOperation: deleteEntryOperationWithEvents - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIds.ts deleted file mode 100644 index d8cdc519e27..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIds.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IGetEntriesByIds } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetByIdsParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetEntriesByIds implements IGetEntriesByIds { - private readonly operation: CmsEntryStorageOperations["getByIds"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getByIds"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsGetByIdsParams) { - const result = await this.operation(model, params); - - return await Promise.all( - result.map(entry => { - return this.transform(model, entry); - }) - ); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsNotDeleted.ts deleted file mode 100644 index eb9e036ff5d..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsNotDeleted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IGetEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetEntriesByIdsNotDeleted implements IGetEntriesByIds { - private getEntriesByIds: IGetEntriesByIds; - - constructor(getEntriesByIds: IGetEntriesByIds) { - this.getEntriesByIds = getEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetByIdsParams) { - const entries = await this.getEntriesByIds.execute(model, params); - return entries.filter(entry => !entry.wbyDeleted); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsSecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsSecure.ts deleted file mode 100644 index 114fbf44fa7..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/GetEntriesByIdsSecure.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import type { IGetEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetEntriesByIdsSecure implements IGetEntriesByIds { - private accessControl: AccessControl; - private getEntriesByIds: IGetEntriesByIds; - - constructor(accessControl: AccessControl, getEntriesByIds: IGetEntriesByIds) { - this.accessControl = accessControl; - this.getEntriesByIds = getEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetByIdsParams) { - await this.accessControl.ensureCanAccessEntry({ model }); - - const entries = await this.getEntriesByIds.execute(model, params); - - return filterAsync(entries, async entry => { - return this.accessControl.canAccessEntry({ model, entry }); - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/index.ts deleted file mode 100644 index 69e4ef69bdc..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetEntriesByIds/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GetEntriesByIds } from "./GetEntriesByIds.js"; -import { GetEntriesByIdsSecure } from "./GetEntriesByIdsSecure.js"; -import { GetEntriesByIdsNotDeleted } from "./GetEntriesByIdsNotDeleted.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetEntriesByIdsUseCasesParams { - operation: CmsEntryStorageOperations["getByIds"]; - accessControl: AccessControl; - transform: ITransformEntryCallable; -} - -export const getEntriesByIdsUseCases = (params: GetEntriesByIdsUseCasesParams) => { - const getEntriesByIds = new GetEntriesByIds(params.operation, params.transform); - const getEntriesByIdsSecure = new GetEntriesByIdsSecure(params.accessControl, getEntriesByIds); - const getEntriesByIdsNotDeleted = new GetEntriesByIdsNotDeleted(getEntriesByIdsSecure); - - return { - getEntriesByIdsUseCase: getEntriesByIdsNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIds.ts deleted file mode 100644 index 4f8977b6d6f..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIds.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IGetLatestEntriesByIds } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetLatestByIdsParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetLatestEntriesByIds implements IGetLatestEntriesByIds { - private readonly operation: CmsEntryStorageOperations["getLatestByIds"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getLatestByIds"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsGetLatestByIdsParams) { - const result = await this.operation(model, params); - - return await Promise.all( - result.map(async entry => { - return this.transform(model, entry); - }) - ); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsNotDeleted.ts deleted file mode 100644 index e7ac8879e60..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsNotDeleted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IGetLatestEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetLatestByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetLatestEntriesByIdsNotDeleted implements IGetLatestEntriesByIds { - private getLatestEntriesByIds: IGetLatestEntriesByIds; - - constructor(getLatestEntriesByIds: IGetLatestEntriesByIds) { - this.getLatestEntriesByIds = getLatestEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetLatestByIdsParams) { - const entries = await this.getLatestEntriesByIds.execute(model, params); - return entries.filter(entry => !entry.wbyDeleted); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsSecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsSecure.ts deleted file mode 100644 index 9c27def2855..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/GetLatestEntriesByIdsSecure.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import type { IGetLatestEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetLatestByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetLatestEntriesByIdsSecure implements IGetLatestEntriesByIds { - private accessControl: AccessControl; - private getLatestEntriesByIds: IGetLatestEntriesByIds; - - constructor(accessControl: AccessControl, getLatestEntriesByIds: IGetLatestEntriesByIds) { - this.accessControl = accessControl; - this.getLatestEntriesByIds = getLatestEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetLatestByIdsParams) { - await this.accessControl.ensureCanAccessEntry({ model }); - - const entries = await this.getLatestEntriesByIds.execute(model, params); - - return filterAsync(entries, async entry => { - return this.accessControl.canAccessEntry({ model, entry }); - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/index.ts deleted file mode 100644 index 7d57683d3ab..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestEntriesByIds/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { GetLatestEntriesByIds } from "./GetLatestEntriesByIds.js"; -import { GetLatestEntriesByIdsNotDeleted } from "./GetLatestEntriesByIdsNotDeleted.js"; -import { GetLatestEntriesByIdsSecure } from "./GetLatestEntriesByIdsSecure.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetLatestEntriesByIdsUseCasesParams { - operation: CmsEntryStorageOperations["getLatestByIds"]; - accessControl: AccessControl; - transform: ITransformEntryCallable; -} - -export const getLatestEntriesByIdsUseCases = (params: GetLatestEntriesByIdsUseCasesParams) => { - const getLatestEntriesByIds = new GetLatestEntriesByIds(params.operation, params.transform); - const getLatestEntriesByIdsSecure = new GetLatestEntriesByIdsSecure( - params.accessControl, - getLatestEntriesByIds - ); - const getLatestEntriesByIdsNotDeleted = new GetLatestEntriesByIdsNotDeleted( - getLatestEntriesByIdsSecure - ); - - return { - getLatestEntriesByIdsUseCase: getLatestEntriesByIdsNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryId.ts deleted file mode 100644 index 1c46b0b8383..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryId.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IGetLatestRevisionByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetLatestRevisionParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetLatestRevisionByEntryId implements IGetLatestRevisionByEntryId { - private readonly operation: CmsEntryStorageOperations["getLatestRevisionByEntryId"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getLatestRevisionByEntryId"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute( - model: CmsModel, - params: CmsEntryStorageOperationsGetLatestRevisionParams - ) { - const entry = await this.operation(model, params); - - if (!entry) { - return null; - } - return this.transform(model, entry); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdDeleted.ts deleted file mode 100644 index 5aa742b20a2..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdDeleted.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IGetLatestRevisionByEntryId } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetLatestRevisionParams, CmsModel } from "~/types/index.js"; - -export class GetLatestRevisionByEntryIdDeleted implements IGetLatestRevisionByEntryId { - private getLatestRevisionByEntryId: IGetLatestRevisionByEntryId; - - constructor(getLatestRevisionByEntryId: IGetLatestRevisionByEntryId) { - this.getLatestRevisionByEntryId = getLatestRevisionByEntryId; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetLatestRevisionParams) { - const entry = await this.getLatestRevisionByEntryId.execute(model, params); - - if (!entry || !entry.wbyDeleted) { - return null; - } - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdNotDeleted.ts deleted file mode 100644 index 462b7993a4f..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdNotDeleted.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IGetLatestRevisionByEntryId } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetLatestRevisionParams, CmsModel } from "~/types/index.js"; - -export class GetLatestRevisionByEntryIdNotDeleted implements IGetLatestRevisionByEntryId { - private getLatestRevisionByEntryId: IGetLatestRevisionByEntryId; - - constructor(getLatestRevisionByEntryId: IGetLatestRevisionByEntryId) { - this.getLatestRevisionByEntryId = getLatestRevisionByEntryId; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetLatestRevisionParams) { - const entry = await this.getLatestRevisionByEntryId.execute(model, params); - - if (!entry || entry.wbyDeleted) { - return null; - } - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/index.ts deleted file mode 100644 index 6afdf4afa0e..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetLatestRevisionByEntryId/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { GetLatestRevisionByEntryId } from "./GetLatestRevisionByEntryId.js"; -import { GetLatestRevisionByEntryIdDeleted } from "./GetLatestRevisionByEntryIdDeleted.js"; -import { GetLatestRevisionByEntryIdNotDeleted } from "./GetLatestRevisionByEntryIdNotDeleted.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetLatestRevisionByEntryIdUseCasesParams { - operation: CmsEntryStorageOperations["getLatestRevisionByEntryId"]; - transform: ITransformEntryCallable; -} - -export const getLatestRevisionByEntryIdUseCases = ( - params: GetLatestRevisionByEntryIdUseCasesParams -) => { - const getLatestRevisionByEntryId = new GetLatestRevisionByEntryId( - params.operation, - params.transform - ); - const getLatestRevisionByEntryIdNotDeleted = new GetLatestRevisionByEntryIdNotDeleted( - getLatestRevisionByEntryId - ); - const getLatestRevisionByEntryIdDeleted = new GetLatestRevisionByEntryIdDeleted( - getLatestRevisionByEntryId - ); - - return { - getLatestRevisionByEntryIdUseCase: getLatestRevisionByEntryIdNotDeleted, - getLatestRevisionByEntryIdWithDeletedUseCase: getLatestRevisionByEntryId, - getLatestRevisionByEntryIdDeletedUseCase: getLatestRevisionByEntryIdDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryId.ts deleted file mode 100644 index e967ff3689e..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryId.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IGetPreviousRevisionByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetPreviousRevisionParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetPreviousRevisionByEntryId implements IGetPreviousRevisionByEntryId { - private readonly operation: CmsEntryStorageOperations["getPreviousRevision"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getPreviousRevision"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute( - model: CmsModel, - params: CmsEntryStorageOperationsGetPreviousRevisionParams - ) { - const entry = await this.operation(model, params); - - if (!entry) { - return null; - } - return await this.transform(model, entry); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdNotDeleted.ts deleted file mode 100644 index f5f8558183c..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdNotDeleted.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { IGetPreviousRevisionByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperationsGetPreviousRevisionParams, - CmsModel -} from "~/types/index.js"; - -export class GetPreviousRevisionByEntryIdNotDeleted implements IGetPreviousRevisionByEntryId { - private getPreviousRevisionByEntryId: IGetPreviousRevisionByEntryId; - - constructor(getPreviousRevisionByEntryId: IGetPreviousRevisionByEntryId) { - this.getPreviousRevisionByEntryId = getPreviousRevisionByEntryId; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetPreviousRevisionParams) { - const entry = await this.getPreviousRevisionByEntryId.execute(model, params); - - if (!entry || entry.wbyDeleted) { - return null; - } - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/index.ts deleted file mode 100644 index 4211aaacd7b..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPreviousRevisionByEntryId/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { GetPreviousRevisionByEntryId } from "./GetPreviousRevisionByEntryId.js"; -import { GetPreviousRevisionByEntryIdNotDeleted } from "./GetPreviousRevisionByEntryIdNotDeleted.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetPreviousRevisionByEntryIdUseCasesParams { - operation: CmsEntryStorageOperations["getPreviousRevision"]; - transform: ITransformEntryCallable; -} - -export const getPreviousRevisionByEntryIdUseCases = ( - params: GetPreviousRevisionByEntryIdUseCasesParams -) => { - const getPreviousRevisionByEntryId = new GetPreviousRevisionByEntryId( - params.operation, - params.transform - ); - const getPreviousRevisionByEntryIdNotDeleted = new GetPreviousRevisionByEntryIdNotDeleted( - getPreviousRevisionByEntryId - ); - - return { - getPreviousRevisionByEntryIdUseCase: getPreviousRevisionByEntryIdNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIds.ts deleted file mode 100644 index 6f46be68175..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIds.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { IGetPublishedEntriesByIds } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetPublishedByIdsParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetPublishedEntriesByIds implements IGetPublishedEntriesByIds { - private readonly operation: CmsEntryStorageOperations["getPublishedByIds"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getPublishedByIds"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute( - model: CmsModel, - params: CmsEntryStorageOperationsGetPublishedByIdsParams - ) { - const result = await this.operation(model, params); - - return await Promise.all( - result.map(async entry => { - return await this.transform(model, entry); - }) - ); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsNotDeleted.ts deleted file mode 100644 index a3aa2c5ebd9..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsNotDeleted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IGetPublishedEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetPublishedByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetPublishedEntriesByIdsNotDeleted implements IGetPublishedEntriesByIds { - private getPublishedEntriesByIds: IGetPublishedEntriesByIds; - - constructor(getLatestEntriesByIds: IGetPublishedEntriesByIds) { - this.getPublishedEntriesByIds = getLatestEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetPublishedByIdsParams) { - const entries = await this.getPublishedEntriesByIds.execute(model, params); - return entries.filter(entry => !entry.wbyDeleted); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsSecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsSecure.ts deleted file mode 100644 index 537fbe71dd4..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/GetPublishedEntriesByIdsSecure.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import type { IGetPublishedEntriesByIds } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetPublishedByIdsParams, CmsModel } from "~/types/index.js"; - -export class GetPublishedEntriesByIdsSecure implements IGetPublishedEntriesByIds { - private accessControl: AccessControl; - private getPublishedEntriesByIds: IGetPublishedEntriesByIds; - - constructor(accessControl: AccessControl, getPublishedEntriesByIds: IGetPublishedEntriesByIds) { - this.accessControl = accessControl; - this.getPublishedEntriesByIds = getPublishedEntriesByIds; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetPublishedByIdsParams) { - await this.accessControl.ensureCanAccessEntry({ model }); - - const entries = await this.getPublishedEntriesByIds.execute(model, params); - - return filterAsync(entries, async entry => { - return this.accessControl.canAccessEntry({ model, entry }); - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/index.ts deleted file mode 100644 index 8dda0c7fb9a..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedEntriesByIds/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { GetPublishedEntriesByIds } from "./GetPublishedEntriesByIds.js"; -import { GetPublishedEntriesByIdsNotDeleted } from "./GetPublishedEntriesByIdsNotDeleted.js"; -import { GetPublishedEntriesByIdsSecure } from "./GetPublishedEntriesByIdsSecure.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetPublishedEntriesByIdsUseCasesParams { - operation: CmsEntryStorageOperations["getPublishedByIds"]; - accessControl: AccessControl; - transform: ITransformEntryCallable; -} - -export const getPublishedEntriesByIdsUseCases = ( - params: GetPublishedEntriesByIdsUseCasesParams -) => { - const getPublishedEntriesByIds = new GetPublishedEntriesByIds( - params.operation, - params.transform - ); - const getPublishedEntriesByIdsSecure = new GetPublishedEntriesByIdsSecure( - params.accessControl, - getPublishedEntriesByIds - ); - const getPublishedEntriesByIdsNotDeleted = new GetPublishedEntriesByIdsNotDeleted( - getPublishedEntriesByIdsSecure - ); - - return { - getPublishedEntriesByIdsUseCase: getPublishedEntriesByIdsNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryId.ts deleted file mode 100644 index f3b02e05756..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryId.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IGetPublishedRevisionByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetPublishedRevisionParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetPublishedRevisionByEntryId implements IGetPublishedRevisionByEntryId { - private readonly operation: CmsEntryStorageOperations["getPublishedRevisionByEntryId"]; - private readonly transform: ITransformEntryCallable; - - constructor( - operation: CmsEntryStorageOperations["getPublishedRevisionByEntryId"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetPublishedRevisionParams) { - const entry = await this.operation(model, params); - - if (!entry) { - return null; - } - return await this.transform(model, entry); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdNotDeleted.ts deleted file mode 100644 index 4fff86e650c..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdNotDeleted.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { IGetPublishedRevisionByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperationsGetPublishedRevisionParams, - CmsModel -} from "~/types/index.js"; - -export class GetPublishedRevisionByEntryIdNotDeleted implements IGetPublishedRevisionByEntryId { - private getPublishedRevisionByEntryId: IGetPublishedRevisionByEntryId; - - constructor(getPublishedRevisionByEntryId: IGetPublishedRevisionByEntryId) { - this.getPublishedRevisionByEntryId = getPublishedRevisionByEntryId; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetPublishedRevisionParams) { - const entry = await this.getPublishedRevisionByEntryId.execute(model, params); - - if (!entry || entry.wbyDeleted) { - return null; - } - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/index.ts deleted file mode 100644 index 56c6279bcf1..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetPublishedRevisionByEntryId/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { GetPublishedRevisionByEntryId } from "./GetPublishedRevisionByEntryId.js"; -import { GetPublishedRevisionByEntryIdNotDeleted } from "./GetPublishedRevisionByEntryIdNotDeleted.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetPublishedRevisionByEntryIdUseCasesParams { - operation: CmsEntryStorageOperations["getPublishedRevisionByEntryId"]; - transform: ITransformEntryCallable; -} - -export const getPublishedRevisionByEntryIdUseCases = ( - params: GetPublishedRevisionByEntryIdUseCasesParams -) => { - const getPublishedRevisionByEntryId = new GetPublishedRevisionByEntryId( - params.operation, - params.transform - ); - const getPublishedRevisionByEntryIdNotDeleted = new GetPublishedRevisionByEntryIdNotDeleted( - getPublishedRevisionByEntryId - ); - - return { - getPublishedRevisionByEntryIdUseCase: getPublishedRevisionByEntryIdNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionById.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionById.ts deleted file mode 100644 index 8790c35a9b3..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionById.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { IGetRevisionById } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetRevisionParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetRevisionById implements IGetRevisionById { - private readonly operation: CmsEntryStorageOperations["getRevisionById"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getRevisionById"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsGetRevisionParams) { - const result = await this.operation(model, params); - if (!result) { - return null; - } - return await this.transform(model, result); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionByIdNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionByIdNotDeleted.ts deleted file mode 100644 index c34653d3600..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/GetRevisionByIdNotDeleted.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IGetRevisionById } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetRevisionParams, CmsModel } from "~/types/index.js"; - -export class GetRevisionByIdNotDeleted implements IGetRevisionById { - private getRevisionById: IGetRevisionById; - - constructor(getRevisionById: IGetRevisionById) { - this.getRevisionById = getRevisionById; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetRevisionParams) { - const entry = await this.getRevisionById.execute(model, params); - - if (!entry || entry.wbyDeleted) { - return null; - } - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/index.ts deleted file mode 100644 index ac44656bf49..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionById/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { GetRevisionById } from "./GetRevisionById.js"; -import { GetRevisionByIdNotDeleted } from "./GetRevisionByIdNotDeleted.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetRevisionByIdUseCasesParams { - operation: CmsEntryStorageOperations["getRevisionById"]; - transform: ITransformEntryCallable; -} - -export const getRevisionByIdUseCases = (params: GetRevisionByIdUseCasesParams) => { - const getRevisionById = new GetRevisionById(params.operation, params.transform); - const getRevisionByIdNotDeleted = new GetRevisionByIdNotDeleted(getRevisionById); - - return { - getRevisionByIdUseCase: getRevisionByIdNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryId.ts deleted file mode 100644 index a78f57c3953..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryId.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { IGetRevisionsByEntryId } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsGetRevisionParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class GetRevisionsByEntryId implements IGetRevisionsByEntryId { - private readonly operation: CmsEntryStorageOperations["getRevisions"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["getRevisions"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsGetRevisionParams) { - const result = await this.operation(model, params); - - return await Promise.all( - result.map(async entry => { - return await this.transform(model, entry); - }) - ); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryIdNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryIdNotDeleted.ts deleted file mode 100644 index e1953910866..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/GetRevisionsByEntryIdNotDeleted.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IGetRevisionsByEntryId } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsGetRevisionParams, CmsModel } from "~/types/index.js"; - -export class GetRevisionsByEntryIdNotDeleted implements IGetRevisionsByEntryId { - private getRevisionsByEntryId: IGetRevisionsByEntryId; - - constructor(getRevisionsByEntryId: IGetRevisionsByEntryId) { - this.getRevisionsByEntryId = getRevisionsByEntryId; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsGetRevisionParams) { - const entries = await this.getRevisionsByEntryId.execute(model, params); - return entries.filter(entry => !entry.wbyDeleted); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/index.ts deleted file mode 100644 index c37c295bbef..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/GetRevisionsByEntryId/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GetRevisionsByEntryId } from "./GetRevisionsByEntryId.js"; -import { GetRevisionsByEntryIdNotDeleted } from "./GetRevisionsByEntryIdNotDeleted.js"; -import type { CmsEntryStorageOperations } from "~/types/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -interface GetRevisionsByEntryIdUseCasesParams { - operation: CmsEntryStorageOperations["getRevisions"]; - accessControl: AccessControl; - transform: ITransformEntryCallable; -} - -export const getRevisionsByEntryIdUseCases = (params: GetRevisionsByEntryIdUseCasesParams) => { - const getRevisionsByEntryId = new GetRevisionsByEntryId(params.operation, params.transform); - const getRevisionsByEntryIdNotDeleted = new GetRevisionsByEntryIdNotDeleted( - getRevisionsByEntryId - ); - - return { - getRevisionsByEntryIdUseCase: getRevisionsByEntryIdNotDeleted - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntry.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntry.ts deleted file mode 100644 index 973cb944618..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntry.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { IGetEntry, IListEntriesOperation } from "~/crud/contentEntry/abstractions/index.js"; -import type { CmsEntryGetParams, CmsModel } from "~/types/index.js"; -import { NotFoundError } from "@webiny/handler-graphql"; - -export class GetEntry implements IGetEntry { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryGetParams) { - const listParams = { - ...params, - limit: 1 - }; - - const { items } = await this.listEntries.execute(model, listParams); - - const item = items.shift(); - - if (!item) { - throw new NotFoundError(`Entry not found!`); - } - - return item; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntrySecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntrySecure.ts deleted file mode 100644 index 62f0d4f9728..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/GetEntrySecure.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { IGetEntry } from "../../abstractions/index.js"; -import type { CmsEntryGetParams, CmsModel } from "~/types/index.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export class GetEntrySecure implements IGetEntry { - private accessControl: AccessControl; - private getIdentity: () => SecurityIdentity; - private useCase: IGetEntry; - - constructor( - accessControl: AccessControl, - getIdentity: () => SecurityIdentity, - useCase: IGetEntry - ) { - this.accessControl = accessControl; - this.getIdentity = getIdentity; - this.useCase = useCase; - } - - async execute(model: CmsModel, params: CmsEntryGetParams) { - await this.accessControl.ensureCanAccessEntry({ model }); - - const where = { ...params.where }; - - /** - * Possibly only get records which are owned by current user. - * Or if searching for the owner set that value - in the case that user can see other entries than their own. - */ - if (await this.accessControl.canAccessOnlyOwnedEntries({ model })) { - where.createdBy = this.getIdentity().id; - } - - return await this.useCase.execute(model, { - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntries.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntries.ts deleted file mode 100644 index 629f2a72c64..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntries.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - IListEntriesOperation, - IListEntries -} from "~/crud/contentEntry/abstractions/index.js"; -import type { - CmsEntry, - CmsEntryListParams, - CmsEntryMeta, - CmsEntryValues, - CmsModel -} from "~/types/index.js"; -import WebinyError from "@webiny/error"; - -export class ListEntries implements IListEntries { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute( - model: CmsModel, - params?: CmsEntryListParams - ): Promise<[CmsEntry[], CmsEntryMeta]> { - const { where: initialWhere, limit: initialLimit, fields } = params || {}; - - try { - const limit = initialLimit && initialLimit > 0 ? initialLimit : 50; - const where = { ...initialWhere }; - const listParams = { ...params, where, limit }; - - const { hasMoreItems, totalCount, cursor, items } = await this.listEntries.execute( - model, - listParams - ); - - const meta = { - hasMoreItems, - totalCount, - /** - * Cursor should be null if there are no more items to load. - * Just make sure of that, disregarding what is returned from the storageOperations.entries.list method. - */ - cursor: hasMoreItems ? cursor : null - }; - - return [items as CmsEntry[], meta]; - } catch (ex) { - throw new WebinyError( - ex.message || "Error while fetching entries from storage.", - ex.code || "LIST_ENTRIES_ERROR", - { - ...ex.data, - params, - error: { - message: ex.message, - code: ex.code, - data: ex.data - }, - model, - fields - } - ); - } - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperation.ts deleted file mode 100644 index c4feffec6e4..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperation.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsListParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class ListEntriesOperation implements IListEntriesOperation { - private readonly operation: CmsEntryStorageOperations["list"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["list"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const result = await this.operation(model, params); - return { - ...result, - items: await Promise.all( - result.items.map(async entry => { - return this.transform(model, entry); - }) - ) - }; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationDeleted.ts deleted file mode 100644 index e501693a574..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationDeleted.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; - -export class ListEntriesOperationDeleted implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const where = { ...params.where, wbyDeleted: true }; - - return await this.listEntries.execute(model, { - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationLatest.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationLatest.ts deleted file mode 100644 index 0c4d4a670dd..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationLatest.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; - -export class ListEntriesOperationLatest implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const where = { ...params.where, latest: true }; - - return await this.listEntries.execute(model, { - sort: ["createdOn_DESC"], - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationNotDeleted.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationNotDeleted.ts deleted file mode 100644 index 68777bc1633..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationNotDeleted.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; - -export class ListEntriesOperationNotDeleted implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const where = { ...params.where, wbyDeleted_not: true }; - - return await this.listEntries.execute(model, { - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationPublished.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationPublished.ts deleted file mode 100644 index 20d9958247d..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationPublished.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; - -export class ListEntriesOperationPublished implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const where = { ...params.where, published: true }; - - return await this.listEntries.execute(model, { - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithEvents.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithEvents.ts deleted file mode 100644 index 3cd370d1f47..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithEvents.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; -import type { ListEntriesUseCasesTopics } from "./index.js"; - -export class ListEntriesOperationWithEvents implements IListEntriesOperation { - private topics: ListEntriesUseCasesTopics; - private listEntries: IListEntriesOperation; - - constructor(topics: ListEntriesUseCasesTopics, listEntries: IListEntriesOperation) { - this.topics = topics; - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - await this.topics.onEntryBeforeList.publish({ - model, - where: params.where - }); - - return await this.listEntries.execute(model, params); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSearchableFields.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSearchableFields.ts deleted file mode 100644 index c9bcb9f5d37..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSearchableFields.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsContext, CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; -import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; - -export class ListEntriesOperationWithSearchableFields implements IListEntriesOperation { - private context: CmsContext; - private listEntries: IListEntriesOperation; - - constructor(context: CmsContext, listEntries: IListEntriesOperation) { - this.context = context; - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const fields = getSearchableFields({ - fields: model.fields, - plugins: this.context.plugins, - input: params.fields || [] - }); - - return await this.listEntries.execute(model, { ...params, fields }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSort.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSort.ts deleted file mode 100644 index e5cc260ce1a..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithSort.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { - CmsEntryListSort, - CmsEntryStorageOperationsListParams, - CmsModel -} from "~/types/index.js"; - -export class ListEntriesOperationWithSort implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - return await this.listEntries.execute(model, { - ...params, - sort: this.createSort(params.sort) - }); - } - - private createSort(sort?: CmsEntryListSort): CmsEntryListSort { - if (Array.isArray(sort) && sort.filter(Boolean).length > 0) { - return sort; - } - - return ["revisionCreatedOn_DESC"]; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithStatusCheck.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithStatusCheck.ts deleted file mode 100644 index f2d8c44c3be..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesOperationWithStatusCheck.ts +++ /dev/null @@ -1,39 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { IListEntriesOperation } from "../../abstractions/index.js"; -import type { CmsEntryStorageOperationsListParams, CmsModel } from "~/types/index.js"; - -export class ListEntriesOperationWithStatusCheck implements IListEntriesOperation { - private listEntries: IListEntriesOperation; - - constructor(listEntries: IListEntriesOperation) { - this.listEntries = listEntries; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsListParams) { - const { where } = params; - - /** - * Where must contain either latest or published keys. - * We cannot list entries without one of those - */ - if (where.latest && where.published) { - throw new WebinyError( - "Cannot list entries that are both published and latest.", - "LIST_ENTRIES_ERROR", - { - where - } - ); - } else if (!where.latest && !where.published) { - throw new WebinyError( - "Cannot list entries if we do not have latest or published defined.", - "LIST_ENTRIES_ERROR", - { - where - } - ); - } - - return await this.listEntries.execute(model, params); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesSecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesSecure.ts deleted file mode 100644 index 27dad561450..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/ListEntriesSecure.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { IListEntries } from "../../abstractions/index.js"; -import type { - CmsEntry, - CmsEntryListParams, - CmsEntryMeta, - CmsEntryValues, - CmsModel -} from "~/types/index.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export class ListEntriesSecure implements IListEntries { - private accessControl: AccessControl; - private getIdentity: () => SecurityIdentity; - private useCase: IListEntries; - - constructor( - accessControl: AccessControl, - getIdentity: () => SecurityIdentity, - useCase: IListEntries - ) { - this.accessControl = accessControl; - this.getIdentity = getIdentity; - this.useCase = useCase; - } - - async execute( - model: CmsModel, - params?: CmsEntryListParams - ): Promise<[CmsEntry[], CmsEntryMeta]> { - await this.accessControl.ensureCanAccessEntry({ model }); - const { where: initialWhere } = params || {}; - const where = { ...initialWhere }; - - /** - * Possibly only get records which are owned by current user. - * Or if searching for the owner set that value - in the case that user can see other entries than their own. - */ - if (await this.accessControl.canAccessOnlyOwnedEntries({ model })) { - where.createdBy = this.getIdentity().id; - } - - return await this.useCase.execute(model, { - ...params, - where - }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/index.ts deleted file mode 100644 index a802971447d..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/ListEntries/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { - CmsContext, - CmsEntryStorageOperations, - EntryBeforeListTopicParams -} from "~/types/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import { ListEntriesOperationWithSearchableFields } from "./ListEntriesOperationWithSearchableFields.js"; -import { ListEntriesOperation } from "./ListEntriesOperation.js"; -import { ListEntriesOperationWithEvents } from "./ListEntriesOperationWithEvents.js"; -import { ListEntriesOperationWithSort } from "./ListEntriesOperationWithSort.js"; -import { ListEntriesOperationWithStatusCheck } from "./ListEntriesOperationWithStatusCheck.js"; -import { ListEntriesSecure } from "~/crud/contentEntry/useCases/ListEntries/ListEntriesSecure.js"; -import { ListEntriesOperationNotDeleted } from "./ListEntriesOperationNotDeleted.js"; -import { ListEntriesOperationDeleted } from "./ListEntriesOperationDeleted.js"; -import { ListEntriesOperationLatest } from "./ListEntriesOperationLatest.js"; -import { ListEntriesOperationPublished } from "./ListEntriesOperationPublished.js"; -import { ListEntries } from "./ListEntries.js"; -import { GetEntry } from "./GetEntry.js"; -import { GetEntrySecure } from "./GetEntrySecure.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export interface ListEntriesUseCasesTopics { - onEntryBeforeList: Topic; -} - -interface ListEntriesUseCasesParams { - operation: CmsEntryStorageOperations["list"]; - accessControl: AccessControl; - topics: ListEntriesUseCasesTopics; - context: CmsContext; - getIdentity: () => SecurityIdentity; - transform: ITransformEntryCallable; -} - -export const listEntriesUseCases = (params: ListEntriesUseCasesParams) => { - const listOperation = new ListEntriesOperation(params.operation, params.transform); - const listOperationWithEvents = new ListEntriesOperationWithEvents( - params.topics, - listOperation - ); - const listOperationWithEventsSort = new ListEntriesOperationWithSort(listOperationWithEvents); - const listOperationWithEventsSortStatusCheck = new ListEntriesOperationWithStatusCheck( - listOperationWithEventsSort - ); - const listOperationWithEventsSortStatusCheckFields = - new ListEntriesOperationWithSearchableFields( - params.context, - listOperationWithEventsSortStatusCheck - ); - - const listNotDeletedOperation = new ListEntriesOperationNotDeleted( - listOperationWithEventsSortStatusCheckFields - ); - - const listDeletedOperation = new ListEntriesOperationDeleted( - listOperationWithEventsSortStatusCheckFields - ); - - // List - const listEntriesOperation = new ListEntries(listNotDeletedOperation); - const listEntriesUseCase = new ListEntriesSecure( - params.accessControl, - params.getIdentity, - listEntriesOperation - ); - - // List latest - const listLatestOperation = new ListEntriesOperationLatest(listNotDeletedOperation); - const listLatestEntries = new ListEntries(listLatestOperation); - const listLatestUseCase = new ListEntriesSecure( - params.accessControl, - params.getIdentity, - listLatestEntries - ); - - // List deleted - const listLatestDeletedOperation = new ListEntriesOperationLatest(listDeletedOperation); - const listDeletedEntries = new ListEntries(listLatestDeletedOperation); - const listDeletedUseCase = new ListEntriesSecure( - params.accessControl, - params.getIdentity, - listDeletedEntries - ); - - // List published - const listPublishedOperation = new ListEntriesOperationPublished(listNotDeletedOperation); - const listPublishedEntries = new ListEntries(listPublishedOperation); - const listPublishedUseCase = new ListEntriesSecure( - params.accessControl, - params.getIdentity, - listPublishedEntries - ); - - // Get - const getEntryNotDeleted = new GetEntry(listNotDeletedOperation); - const getEntryUseCase = new GetEntrySecure( - params.accessControl, - params.getIdentity, - getEntryNotDeleted - ); - - return { - listEntriesUseCase, - listLatestUseCase, - listDeletedUseCase, - listPublishedUseCase, - getEntryUseCase - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBin.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBin.ts deleted file mode 100644 index 0ce8c2c81d1..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBin.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NotFoundError } from "@webiny/handler-graphql"; -import type { - IGetLatestRevisionByEntryId, - IRestoreEntryFromBin, - IRestoreEntryFromBinOperation -} from "~/crud/contentEntry/abstractions/index.js"; -import type { TransformEntryRestoreFromBin } from "./TransformEntryRestoreFromBin.js"; -import type { CmsModel } from "~/types/index.js"; -import { parseIdentifier } from "@webiny/utils"; - -export class RestoreEntryFromBin implements IRestoreEntryFromBin { - private getEntry: IGetLatestRevisionByEntryId; - private transformEntry: TransformEntryRestoreFromBin; - private restoreEntry: IRestoreEntryFromBinOperation; - - constructor( - getEntry: IGetLatestRevisionByEntryId, - transformEntry: TransformEntryRestoreFromBin, - restoreEntry: IRestoreEntryFromBinOperation - ) { - this.getEntry = getEntry; - this.transformEntry = transformEntry; - this.restoreEntry = restoreEntry; - } - - async execute(model: CmsModel, id: string) { - const { id: entryId } = parseIdentifier(id); - const entryToRestore = await this.getEntry.execute(model, { id: entryId }); - - if (!entryToRestore) { - throw new NotFoundError(`Entry "${id}" was not found!`); - } - - const { entry, storageEntry } = await this.transformEntry.execute(model, entryToRestore); - - return await this.restoreEntry.execute(model, { entry, storageEntry }); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperation.ts deleted file mode 100644 index 8c02ec88096..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperation.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { IRestoreEntryFromBinOperation } from "~/crud/contentEntry/abstractions/index.js"; -import type { - CmsEntryStorageOperations, - CmsEntryStorageOperationsRestoreFromBinParams, - CmsModel -} from "~/types/index.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; - -export class RestoreEntryFromBinOperation implements IRestoreEntryFromBinOperation { - private readonly operation: CmsEntryStorageOperations["restoreFromBin"]; - private readonly transform: ITransformEntryCallable; - - public constructor( - operation: CmsEntryStorageOperations["restoreFromBin"], - transform: ITransformEntryCallable - ) { - this.operation = operation; - this.transform = transform; - } - - public async execute(model: CmsModel, params: CmsEntryStorageOperationsRestoreFromBinParams) { - const result = await this.operation(model, params); - - return await this.transform(model, result); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperationWithEvents.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperationWithEvents.ts deleted file mode 100644 index ab3717bb94b..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinOperationWithEvents.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { IRestoreEntryFromBinOperation } from "~/crud/contentEntry/abstractions/index.js"; - -import type { RestoreEntryFromBinUseCasesTopics } from "./index.js"; -import type { CmsEntryStorageOperationsRestoreFromBinParams, CmsModel } from "~/types/index.js"; -import WebinyError from "@webiny/error"; - -export class RestoreEntryFromBinOperationWithEvents implements IRestoreEntryFromBinOperation { - private topics: RestoreEntryFromBinUseCasesTopics; - private operation: IRestoreEntryFromBinOperation; - - constructor( - topics: RestoreEntryFromBinUseCasesTopics, - operation: IRestoreEntryFromBinOperation - ) { - this.topics = topics; - this.operation = operation; - } - - async execute(model: CmsModel, params: CmsEntryStorageOperationsRestoreFromBinParams) { - const entry = params.entry; - try { - await this.topics.onEntryBeforeRestoreFromBin.publish({ - entry, - model - }); - - const result = await this.operation.execute(model, params); - - await this.topics.onEntryAfterRestoreFromBin.publish({ - entry, - storageEntry: result, - model - }); - - return result; - } catch (ex) { - await this.topics.onEntryRestoreFromBinError.publish({ - entry, - model, - error: ex - }); - throw new WebinyError( - ex.message || "Could not restore entry from bin.", - ex.code || "RESTORE_FROM_BIN_ERROR", - { - entry - } - ); - } - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinSecure.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinSecure.ts deleted file mode 100644 index fdf2bce829a..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/RestoreEntryFromBinSecure.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { IRestoreEntryFromBin } from "~/crud/contentEntry/abstractions/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import type { CmsModel } from "~/types/index.js"; - -export class RestoreEntryFromBinSecure implements IRestoreEntryFromBin { - private accessControl: AccessControl; - private useCase: IRestoreEntryFromBin; - - constructor(accessControl: AccessControl, useCase: IRestoreEntryFromBin) { - this.accessControl = accessControl; - this.useCase = useCase; - } - - async execute(model: CmsModel, id: string) { - await this.accessControl.ensureCanAccessEntry({ model, rwd: "d" }); - return await this.useCase.execute(model, id); - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/TransformEntryRestoreFromBin.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/TransformEntryRestoreFromBin.ts deleted file mode 100644 index 6b6a0229029..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/TransformEntryRestoreFromBin.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; -import { getDate } from "~/utils/date.js"; -import { getIdentity } from "~/utils/identity.js"; -import type { - CmsContext, - CmsEntry, - CmsEntryStorageOperationsMoveToBinParams, - CmsModel -} from "~/types/index.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export class TransformEntryRestoreFromBin { - private context: CmsContext; - private getIdentity: () => SecurityIdentity; - - constructor(context: CmsContext, getIdentity: () => SecurityIdentity) { - this.context = context; - this.getIdentity = getIdentity; - } - async execute( - model: CmsModel, - initialEntry: CmsEntry - ): Promise { - const originalEntry = await entryFromStorageTransform(this.context, model, initialEntry); - const entry = await this.createRestoreFromBinEntryData(model, originalEntry); - const storageEntry = await entryToStorageTransform(this.context, model, entry); - - return { - entry, - storageEntry - }; - } - - private async createRestoreFromBinEntryData(model: CmsModel, originalEntry: CmsEntry) { - const currentDateTime = new Date().toISOString(); - const currentIdentity = this.getIdentity(); - - const entry: CmsEntry = { - ...originalEntry, - wbyDeleted: false, - - /** - * Entry location fields. 👇 - */ - location: { - folderId: originalEntry.binOriginalFolderId - }, - binOriginalFolderId: null, - - /** - * Entry-level meta fields. 👇 - */ - restoredOn: getDate(currentDateTime, null), - restoredBy: getIdentity(currentIdentity, null), - - /** - * Revision-level meta fields. 👇 - */ - revisionRestoredOn: getDate(currentDateTime, null), - revisionRestoredBy: getIdentity(currentIdentity, null) - }; - - return entry; - } -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/index.ts deleted file mode 100644 index c950abf2356..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/RestoreEntryFromBin/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { - CmsContext, - CmsEntryStorageOperations, - OnEntryAfterRestoreFromBinTopicParams, - OnEntryBeforeRestoreFromBinTopicParams, - OnEntryRestoreFromBinErrorTopicParams -} from "~/types/index.js"; -import type { IGetLatestRevisionByEntryId } from "~/crud/contentEntry/abstractions/index.js"; -import type { AccessControl } from "~/crud/AccessControl/AccessControl.js"; -import { RestoreEntryFromBinOperation } from "./RestoreEntryFromBinOperation.js"; -import { RestoreEntryFromBinOperationWithEvents } from "./RestoreEntryFromBinOperationWithEvents.js"; -import { TransformEntryRestoreFromBin } from "./TransformEntryRestoreFromBin.js"; -import { RestoreEntryFromBin } from "./RestoreEntryFromBin.js"; -import { RestoreEntryFromBinSecure } from "./RestoreEntryFromBinSecure.js"; -import type { ITransformEntryCallable } from "~/utils/entryStorage.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export interface RestoreEntryFromBinUseCasesTopics { - onEntryBeforeRestoreFromBin: Topic; - onEntryAfterRestoreFromBin: Topic; - onEntryRestoreFromBinError: Topic; -} - -interface RestoreEntryFromBinUseCasesParams { - restoreOperation: CmsEntryStorageOperations["restoreFromBin"]; - getEntry: IGetLatestRevisionByEntryId; - accessControl: AccessControl; - topics: RestoreEntryFromBinUseCasesTopics; - context: CmsContext; - getIdentity: () => SecurityIdentity; - transform: ITransformEntryCallable; -} - -export const restoreEntryFromBinUseCases = (params: RestoreEntryFromBinUseCasesParams) => { - const restoreEntryOperation = new RestoreEntryFromBinOperation( - params.restoreOperation, - params.transform - ); - const restoreEntryOperationWithEvents = new RestoreEntryFromBinOperationWithEvents( - params.topics, - restoreEntryOperation - ); - const restoreTransform = new TransformEntryRestoreFromBin(params.context, params.getIdentity); - const restoreEntry = new RestoreEntryFromBin( - params.getEntry, - restoreTransform, - restoreEntryOperationWithEvents - ); - const restoreEntrySecure = new RestoreEntryFromBinSecure(params.accessControl, restoreEntry); - - return { - restoreEntryFromBinUseCase: restoreEntrySecure - }; -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/useCases/index.ts b/packages/api-headless-cms/src/crud/contentEntry/useCases/index.ts deleted file mode 100644 index 4cde82ea59a..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/useCases/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export * from "./DeleteEntry/index.js"; -export * from "./GetEntriesByIds/index.js"; -export * from "./GetLatestEntriesByIds/index.js"; -export * from "./GetLatestRevisionByEntryId/index.js"; -export * from "./GetPreviousRevisionByEntryId/index.js"; -export * from "./GetPublishedEntriesByIds/index.js"; -export * from "./GetPublishedRevisionByEntryId/index.js"; -export * from "./GetRevisionById/index.js"; -export * from "./GetRevisionsByEntryId/index.js"; -export * from "./ListEntries/index.js"; -export * from "./RestoreEntryFromBin/index.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts deleted file mode 100644 index f553dcc3832..00000000000 --- a/packages/api-headless-cms/src/features/contentEntries/shared/abstractions.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import type { CmsEntry, CmsEntryMeta, CmsEntryListParams, CmsModel } from "~/types/index.js"; -import type { - EntryNotFoundError, - EntryStorageError, - EntryValidationError -} from "~/domains/contentEntries/errors.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; - -export interface IEntriesRepositoryErrors { - base: EntryNotFoundError | EntryStorageError | EntryValidationError; - notAuthorized: NotAuthorizedError; -} - -type RepositoryError = IEntriesRepositoryErrors[keyof IEntriesRepositoryErrors]; - -/** - * EntriesRepository follows CQS (Command-Query Separation): - * - Queries (get, list, getRevisions, etc.): Return data wrapped in Result - * - Commands (create, update, delete, publish, etc.): Return Result - */ -export interface IEntriesRepository { - /** - * Get a specific entry revision by ID. - */ - getById(model: CmsModel, id: string): Promise>; - - /** - * Get the latest revision of an entry by entry ID. - */ - getLatestRevision( - model: CmsModel, - entryId: string - ): Promise>; - - /** - * Get the published revision of an entry by entry ID. - */ - getPublishedRevision( - model: CmsModel, - entryId: string - ): Promise>; - - /** - * Get the previous revision of an entry. - */ - getPreviousRevision( - model: CmsModel, - entryId: string, - version: number - ): Promise>; - - /** - * List entries with filtering and pagination. - */ - list( - model: CmsModel, - params: CmsEntryListParams - ): Promise>; - - /** - * Get all revisions of an entry. - */ - getRevisions(model: CmsModel, entryId: string): Promise>; - - /** - * Get multiple entries by their IDs. - */ - getByIds(model: CmsModel, ids: string[]): Promise>; - - /** - * Create a new entry. - */ - create(model: CmsModel, entry: CmsEntry): Promise>; - - /** - * Create a new revision from an existing entry. - */ - createRevisionFrom( - model: CmsModel, - entry: CmsEntry - ): Promise>; - - /** - * Update an existing entry. - */ - update( - model: CmsModel, - entry: CmsEntry, - ): Promise>; - - /** - * Delete an entry (hard delete with all revisions). - */ - delete(model: CmsModel, id: string): Promise>; - - /** - * Delete a specific revision. - */ - deleteRevision(model: CmsModel, id: string): Promise>; - - /** - * Publish an entry. - */ - publish(model: CmsModel, id: string): Promise>; - - /** - * Unpublish an entry. - */ - unpublish(model: CmsModel, id: string): Promise>; - - /** - * Move an entry to a folder/location. - */ - move(model: CmsModel, id: string, folderId: string): Promise>; - - /** - * Move an entry to bin (soft delete). - */ - moveToBin(model: CmsModel, id: string): Promise>; - - /** - * Restore an entry from bin. - */ - restoreFromBin(model: CmsModel, id: string): Promise>; -} - -export const EntriesRepository = createAbstraction("EntriesRepository"); - -export namespace EntriesRepository { - export type Interface = IEntriesRepository; - export type Error = RepositoryError; -} From 632358c5a75db7e22f333773090cc06f90250a57 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 12 Nov 2025 22:58:06 +0100 Subject: [PATCH 10/71] wip: make domain names singular --- packages/api-headless-cms/MIGRATION_PLAN.md | 18 ++++---- packages/api-headless-cms/src/context.ts | 2 +- .../src/crud/contentEntry.crud.ts | 42 +++++++++---------- .../contentEntry/abstractions/IDeleteEntry.ts | 5 --- .../abstractions/IDeleteEntryOperation.ts | 5 --- .../abstractions/IGetEntriesByIds.ts | 12 ------ .../contentEntry/abstractions/IGetEntry.ts | 5 --- .../abstractions/IGetLatestEntriesByIds.ts | 12 ------ .../IGetLatestRevisionByEntryId.ts | 12 ------ .../IGetPreviousRevisionByEntryId.ts | 12 ------ .../abstractions/IGetPublishedEntriesByIds.ts | 12 ------ .../IGetPublishedRevisionByEntryId.ts | 12 ------ .../abstractions/IGetRevisionById.ts | 12 ------ .../abstractions/IGetRevisionsByEntryId.ts | 12 ------ .../contentEntry/abstractions/IListEntries.ts | 14 ------- .../abstractions/IListEntriesOperation.ts | 12 ------ .../abstractions/IMoveEntryToBinOperation.ts | 5 --- .../abstractions/IRestoreEntryFromBin.ts | 5 --- .../IRestoreEntryFromBinOperation.ts | 12 ------ .../crud/contentEntry/abstractions/index.ts | 16 ------- .../src/crud/contentEntry/markLockedFields.ts | 2 +- .../contentEntry}/errors.ts | 0 .../contentModel}/errors.ts | 0 .../contentModelGroup}/errors.ts | 0 .../ContentEntriesFeature.ts | 0 .../CreateEntry/CreateEntryRepository.ts | 2 +- .../CreateEntry/CreateEntryUseCase.ts | 0 .../CreateEntry/abstractions.ts | 2 +- .../CreateEntry/events.ts | 4 +- .../CreateEntry/feature.ts | 0 .../CreateEntry/index.ts | 0 .../CreateEntryRevisionFromRepository.ts | 2 +- .../CreateEntryRevisionFromUseCase.ts | 4 +- .../CreateEntryRevisionFrom/abstractions.ts | 2 +- .../CreateEntryRevisionFrom/events.ts | 6 +-- .../CreateEntryRevisionFrom/feature.ts | 0 .../CreateEntryRevisionFrom/index.ts | 0 .../DeleteEntry/DeleteEntryRepository.ts | 2 +- .../DeleteEntry/DeleteEntryUseCase.ts | 2 +- .../DeleteEntry/MoveEntryToBinRepository.ts | 2 +- .../DeleteEntry/MoveEntryToBinUseCase.ts | 4 +- .../DeleteEntry/abstractions.ts | 2 +- .../decorators/ForceDeleteDecorator.ts | 2 +- .../DeleteEntry/events.ts | 6 +-- .../DeleteEntry/feature.ts | 0 .../DeleteEntry/index.ts | 0 .../DeleteEntryRevisionRepository.ts | 2 +- .../DeleteEntryRevisionUseCase.ts | 8 ++-- .../DeleteEntryRevision/abstractions.ts | 2 +- .../DeleteEntryRevision/events.ts | 6 +-- .../DeleteEntryRevision/feature.ts | 0 .../DeleteEntryRevision/index.ts | 0 .../DeleteMultipleEntriesRepository.ts | 2 +- .../DeleteMultipleEntriesUseCase.ts | 2 +- .../DeleteMultipleEntries/abstractions.ts | 2 +- .../DeleteMultipleEntries/events.ts | 6 +-- .../DeleteMultipleEntries/feature.ts | 0 .../DeleteMultipleEntries/index.ts | 0 .../GetEntriesByIdsRepository.ts | 2 +- .../GetEntriesByIds/GetEntriesByIdsUseCase.ts | 0 .../GetEntriesByIds/abstractions.ts | 2 +- .../GetEntriesByIdsNotDeletedDecorator.ts | 0 .../GetEntriesByIds/feature.ts | 0 .../GetEntriesByIds/index.ts | 0 .../GetEntry/GetEntryUseCase.ts | 2 +- .../GetEntry/abstractions.ts | 2 +- .../GetEntry/feature.ts | 0 .../GetEntry/index.ts | 0 .../GetEntryById/GetEntryByIdUseCase.ts | 2 +- .../GetEntryById/abstractions.ts | 2 +- .../GetEntryById/feature.ts | 0 .../GetEntryById/index.ts | 0 .../GetLatestEntriesByIdsRepository.ts | 2 +- .../GetLatestEntriesByIdsUseCase.ts | 0 .../GetLatestEntriesByIds/abstractions.ts | 2 +- ...etLatestEntriesByIdsNotDeletedDecorator.ts | 0 .../GetLatestEntriesByIds/feature.ts | 0 .../GetLatestEntriesByIds/index.ts | 0 .../GetLatestRevisionByEntryId/BaseUseCase.ts | 0 .../GetLatestRevisionByEntryIdRepository.ts | 2 +- .../abstractions.ts | 2 +- .../GetLatestRevisionByEntryId/feature.ts | 0 .../GetLatestRevisionByEntryId/index.ts | 0 ...etLatestDeletedRevisionByEntryIdUseCase.ts | 2 +- ...evisionByEntryIdIncludingDeletedUseCase.ts | 0 .../GetLatestRevisionByEntryIdUseCase.ts | 2 +- .../BaseUseCase.ts | 0 .../GetPreviousRevisionByEntryIdRepository.ts | 2 +- .../GetPreviousRevisionByEntryIdUseCase.ts | 2 +- .../abstractions.ts | 2 +- .../GetPreviousRevisionByEntryId/feature.ts | 0 .../GetPreviousRevisionByEntryId/index.ts | 0 .../GetPublishedEntriesByIdsRepository.ts | 2 +- .../GetPublishedEntriesByIdsUseCase.ts | 0 .../GetPublishedEntriesByIds/abstractions.ts | 2 +- ...ublishedEntriesByIdsNotDeletedDecorator.ts | 0 .../GetPublishedEntriesByIds/feature.ts | 0 .../GetPublishedEntriesByIds/index.ts | 0 ...GetPublishedRevisionByEntryIdRepository.ts | 2 +- .../GetPublishedRevisionByEntryIdUseCase.ts | 0 .../abstractions.ts | 2 +- .../GetPublishedRevisionByEntryId/feature.ts | 0 .../GetPublishedRevisionByEntryId/index.ts | 0 .../GetRevisionByIdRepository.ts | 2 +- .../GetRevisionById/GetRevisionByIdUseCase.ts | 0 .../GetRevisionById/abstractions.ts | 2 +- .../GetRevisionByIdNotDeletedDecorator.ts | 2 +- .../GetRevisionById/feature.ts | 0 .../GetRevisionById/index.ts | 0 .../GetRevisionsByEntryIdRepository.ts | 2 +- .../GetRevisionsByEntryIdUseCase.ts | 0 .../GetRevisionsByEntryId/abstractions.ts | 2 +- .../GetRevisionsByEntryId/feature.ts | 0 .../GetRevisionsByEntryId/index.ts | 0 .../GetUniqueFieldValuesRepository.ts | 2 +- .../GetUniqueFieldValuesUseCase.ts | 0 .../GetUniqueFieldValues/abstractions.ts | 2 +- .../GetUniqueFieldValues/errors.ts | 0 .../GetUniqueFieldValues/feature.ts | 0 .../GetUniqueFieldValues/index.ts | 0 .../ListEntries/ListDeletedEntriesUseCase.ts | 0 .../ListEntries/ListEntriesRepository.ts | 2 +- .../ListEntries/ListEntriesUseCase.ts | 0 .../ListEntries/ListLatestEntriesUseCase.ts | 0 .../ListPublishedEntriesUseCase.ts | 0 .../ListEntries/abstractions.ts | 2 +- .../ListEntries/feature.ts | 0 .../ListEntries/index.ts | 0 .../MoveEntry/MoveEntryRepository.ts | 2 +- .../MoveEntry/MoveEntryUseCase.ts | 4 +- .../MoveEntry/abstractions.ts | 4 +- .../MoveEntry/events.ts | 6 +-- .../MoveEntry/feature.ts | 0 .../MoveEntry/index.ts | 0 .../PublishEntry/PublishEntryRepository.ts | 2 +- .../PublishEntry/PublishEntryUseCase.ts | 6 +-- .../PublishEntry/abstractions.ts | 4 +- .../PublishEntry/events.ts | 6 +-- .../PublishEntry/feature.ts | 0 .../PublishEntry/index.ts | 0 .../RepublishEntryRepository.ts | 2 +- .../RepublishEntry/RepublishEntryUseCase.ts | 4 +- .../RepublishEntry/abstractions.ts | 4 +- .../RepublishEntry/events.ts | 6 +-- .../RepublishEntry/feature.ts | 0 .../RepublishEntry/index.ts | 0 .../RestoreEntryFromBinRepository.ts | 2 +- .../RestoreEntryFromBinUseCase.ts | 4 +- .../RestoreEntryFromBin/abstractions.ts | 2 +- .../RestoreEntryFromBin/events.ts | 6 +-- .../RestoreEntryFromBin/feature.ts | 0 .../RestoreEntryFromBin/index.ts | 0 .../UnpublishEntryRepository.ts | 2 +- .../UnpublishEntry/UnpublishEntryUseCase.ts | 6 +-- .../UnpublishEntry/abstractions.ts | 6 +-- .../UnpublishEntry/events.ts | 6 +-- .../UnpublishEntry/feature.ts | 0 .../UnpublishEntry/index.ts | 0 .../UpdateEntry/UpdateEntryRepository.ts | 2 +- .../UpdateEntry/UpdateEntryUseCase.ts | 4 +- .../UpdateEntry/abstractions.ts | 2 +- .../UpdateEntry/events.ts | 4 +- .../UpdateEntry/feature.ts | 0 .../UpdateEntry/index.ts | 0 .../ValidateEntry/ValidateEntryUseCase.ts | 2 +- .../ValidateEntry/abstractions.ts | 4 +- .../ValidateEntry/feature.ts | 0 .../ValidateEntry/index.ts | 0 .../shared/ModelsRepository.ts | 2 +- .../shared/PluginModelsProvider.ts | 0 .../shared/abstractions.ts | 2 +- .../shared/GroupsRepository.ts | 2 +- .../shared/PluginGroupsProvider.ts | 0 .../shared/abstractions.ts | 2 +- .../schema/resolvers/read/resolveGet.ts | 2 +- 175 files changed, 152 insertions(+), 327 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntry.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntryOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntry.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPreviousRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedEntriesByIds.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedRevisionByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionById.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionsByEntryId.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntries.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntriesOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IMoveEntryToBinOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBin.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBinOperation.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/abstractions/index.ts rename packages/api-headless-cms/src/{domains/contentEntries => domain/contentEntry}/errors.ts (100%) rename packages/api-headless-cms/src/{domains/contentModels => domain/contentModel}/errors.ts (100%) rename packages/api-headless-cms/src/{domains/contentModelGroups => domain/contentModelGroup}/errors.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ContentEntriesFeature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/CreateEntryRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/CreateEntryUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/abstractions.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/events.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/CreateEntryRevisionFrom/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/DeleteEntryRepository.ts (93%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/DeleteEntryUseCase.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/MoveEntryToBinRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/MoveEntryToBinUseCase.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/decorators/ForceDeleteDecorator.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/DeleteEntryRevisionRepository.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteEntryRevision/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/abstractions.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/DeleteMultipleEntries/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/GetEntriesByIdsRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/GetEntriesByIdsUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/abstractions.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntriesByIds/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntry/GetEntryUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntry/abstractions.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntryById/GetEntryByIdUseCase.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntryById/abstractions.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntryById/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetEntryById/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/abstractions.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestEntriesByIds/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/BaseUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/abstractions.ts (99%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/BaseUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPreviousRevisionByEntryId/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/abstractions.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedEntriesByIds/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedRevisionByEntryId/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedRevisionByEntryId/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetPublishedRevisionByEntryId/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/GetRevisionByIdRepository.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/GetRevisionByIdUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionById/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionsByEntryId/abstractions.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionsByEntryId/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetRevisionsByEntryId/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/abstractions.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/errors.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/GetUniqueFieldValues/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/ListDeletedEntriesUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/ListEntriesRepository.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/ListEntriesUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/ListLatestEntriesUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/ListPublishedEntriesUseCase.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/abstractions.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ListEntries/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/MoveEntryRepository.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/MoveEntryUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/abstractions.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/MoveEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/PublishEntryRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/PublishEntryUseCase.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/abstractions.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/PublishEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/RepublishEntryRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/RepublishEntryUseCase.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/abstractions.ts (93%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RepublishEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/events.ts (92%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/RestoreEntryFromBin/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/UnpublishEntryRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/UnpublishEntryUseCase.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/abstractions.ts (88%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/events.ts (93%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UnpublishEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/UpdateEntryRepository.ts (95%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/UpdateEntryUseCase.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/abstractions.ts (97%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/events.ts (94%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/UpdateEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ValidateEntry/ValidateEntryUseCase.ts (96%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ValidateEntry/abstractions.ts (86%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ValidateEntry/feature.ts (100%) rename packages/api-headless-cms/src/features/{contentEntries => contentEntry}/ValidateEntry/index.ts (100%) rename packages/api-headless-cms/src/features/{contentModels => contentModel}/shared/ModelsRepository.ts (99%) rename packages/api-headless-cms/src/features/{contentModels => contentModel}/shared/PluginModelsProvider.ts (100%) rename packages/api-headless-cms/src/features/{contentModels => contentModel}/shared/abstractions.ts (98%) rename packages/api-headless-cms/src/features/{contentModelGroups => contentModelGroup}/shared/GroupsRepository.ts (99%) rename packages/api-headless-cms/src/features/{contentModelGroups => contentModelGroup}/shared/PluginGroupsProvider.ts (100%) rename packages/api-headless-cms/src/features/{contentModelGroups => contentModelGroup}/shared/abstractions.ts (98%) diff --git a/packages/api-headless-cms/MIGRATION_PLAN.md b/packages/api-headless-cms/MIGRATION_PLAN.md index 9975e532f87..78f037ddb4e 100644 --- a/packages/api-headless-cms/MIGRATION_PLAN.md +++ b/packages/api-headless-cms/MIGRATION_PLAN.md @@ -259,7 +259,7 @@ export class ModelValidator { **Example: Repository Abstraction (Application Layer)** ```typescript -// features/contentModels/shared/abstractions.ts +// features/contentModel/shared/abstractions.ts import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; @@ -302,7 +302,7 @@ export namespace ModelsRepository { Following the pattern from `api-core` (GroupProvider/TeamProvider), create repositories that transparently handle both database-stored and plugin-defined models. ```typescript -// features/contentModels/shared/ModelsRepository.ts +// features/contentModel/shared/ModelsRepository.ts import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import { ModelsRepository as RepositoryAbstraction } from "./abstractions.js"; @@ -516,7 +516,7 @@ interface IEntriesRepository { **Example: CreateModel Use Case** ```typescript -// features/contentModels/CreateModel/abstractions.ts +// features/contentModel/CreateModel/abstractions.ts import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import { ModelsRepository } from "../shared/abstractions.js"; @@ -557,7 +557,7 @@ export namespace CreateModel { ``` ```typescript -// features/contentModels/CreateModel/CreateModelUseCase.ts +// features/contentModel/CreateModel/CreateModelUseCase.ts import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import { CreateModel as UseCaseAbstraction } from "./abstractions.js"; @@ -666,7 +666,7 @@ crud/contentEntry/useCases/DeleteEntry/ **Target Structure:** ``` -features/contentEntries/DeleteEntry/ +features/contentEntry/DeleteEntry/ ├── abstractions.ts # DI abstractions ├── DeleteEntryUseCase.ts # Use case with events inside ✅ ├── decorators/ @@ -680,7 +680,7 @@ features/contentEntries/DeleteEntry/ **Step 1: Create Abstractions** ```typescript -// features/contentEntries/DeleteEntry/abstractions.ts +// features/contentEntry/DeleteEntry/abstractions.ts import { createAbstraction } from "@webiny/feature/api"; import type { CmsEntry } from "~/domains/contentEntries/CmsEntry.js"; import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; @@ -706,7 +706,7 @@ export namespace DeleteEntry { **Step 2: Refactor Use Case (Merge Operation + Events)** ```typescript -// features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts +// features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts import { createImplementation } from "@webiny/feature/api"; import { DeleteEntry as UseCaseAbstraction } from "./abstractions.js"; import { EntriesRepository } from "../shared/abstractions.js"; @@ -753,7 +753,7 @@ export const DeleteEntryUseCaseImpl = createImplementation({ **Step 3: Refactor ONLY Real Decorators (Not Events)** ```typescript -// features/contentEntries/DeleteEntry/decorators/DeleteEntrySecureDecorator.ts +// features/contentEntry/DeleteEntry/decorators/DeleteEntrySecureDecorator.ts import { createDecorator } from "@webiny/feature/api"; import { DeleteEntry } from "../abstractions.js"; import { AccessControl } from "~/crud/AccessControl/abstractions.js"; @@ -783,7 +783,7 @@ export const DeleteEntrySecureDecorator = createDecorator({ **Step 4: Feature Registration** ```typescript -// features/contentEntries/DeleteEntry/feature.ts +// features/contentEntry/DeleteEntry/feature.ts import { createFeature } from "@webiny/feature"; import { DeleteEntryUseCaseImpl } from "./DeleteEntryUseCase.js"; import { DeleteEntrySecureDecorator } from "./decorators/DeleteEntrySecureDecorator.js"; diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index b28d12724bb..53d4fe494ee 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -16,7 +16,7 @@ import { createImportCrud } from "~/export/crud/importing.js"; import { getSchema } from "~/graphql/getSchema.js"; import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; import { CmsInstallerFeature } from "~/features/installer/feature.js"; -import { ContentEntriesFeature } from "~/features/contentEntries/ContentEntriesFeature.js"; +import { ContentEntriesFeature } from "~/features/contentEntry/ContentEntriesFeature.js"; import { StorageOperations, AccessControl as AccessControlAbstraction, diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index fb922d62159..77d468d05b0 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -51,31 +51,31 @@ import { assignBeforeEntryUpdate } from "./contentEntry/beforeUpdate.js"; import { assignAfterEntryDelete } from "./contentEntry/afterDelete.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; import type { GenericRecord } from "@webiny/api/types.js"; -import { CreateEntryUseCase } from "~/features/contentEntries/CreateEntry/index.js"; -import { CreateEntryRevisionFromUseCase } from "~/features/contentEntries/CreateEntryRevisionFrom/abstractions.js"; -import { UpdateEntryUseCase } from "~/features/contentEntries/UpdateEntry/index.js"; -import { ValidateEntryUseCase } from "~/features/contentEntries/ValidateEntry/abstractions.js"; -import { MoveEntryUseCase } from "~/features/contentEntries/MoveEntry/abstractions.js"; -import { RepublishEntryUseCase } from "~/features/contentEntries/RepublishEntry/abstractions.js"; -import { PublishEntryUseCase } from "~/features/contentEntries/PublishEntry/abstractions.js"; +import { CreateEntryUseCase } from "~/features/contentEntry/CreateEntry/index.js"; +import { CreateEntryRevisionFromUseCase } from "~/features/contentEntry/CreateEntryRevisionFrom/abstractions.js"; +import { UpdateEntryUseCase } from "~/features/contentEntry/UpdateEntry/index.js"; +import { ValidateEntryUseCase } from "~/features/contentEntry/ValidateEntry/abstractions.js"; +import { MoveEntryUseCase } from "~/features/contentEntry/MoveEntry/abstractions.js"; +import { RepublishEntryUseCase } from "~/features/contentEntry/RepublishEntry/abstractions.js"; +import { PublishEntryUseCase } from "~/features/contentEntry/PublishEntry/abstractions.js"; import { ListLatestEntriesUseCase, ListPublishedEntriesUseCase, ListDeletedEntriesUseCase -} from "~/features/contentEntries/ListEntries/index.js"; -import { ListEntriesUseCase } from "~/features/contentEntries/ListEntries/abstractions.js"; -import { GetEntriesByIdsUseCase } from "~/features/contentEntries/GetEntriesByIds/index.js"; -import { GetEntryByIdUseCase } from "~/features/contentEntries/GetEntryById/index.js"; -import { GetPublishedEntriesByIdsUseCase } from "~/features/contentEntries/GetPublishedEntriesByIds/index.js"; -import { GetLatestEntriesByIdsUseCase } from "~/features/contentEntries/GetLatestEntriesByIds/index.js"; -import { GetRevisionsByEntryIdUseCase } from "~/features/contentEntries/GetRevisionsByEntryId/index.js"; -import { GetEntryUseCase } from "~/features/contentEntries/GetEntry/index.js"; -import { DeleteEntryRevisionUseCase } from "~/features/contentEntries/DeleteEntryRevision/index.js"; -import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; -import { DeleteMultipleEntriesUseCase } from "~/features/contentEntries/DeleteMultipleEntries/abstractions.js"; -import { RestoreEntryFromBinUseCase } from "~/features/contentEntries/RestoreEntryFromBin/abstractions.js"; -import { UnpublishEntryUseCase } from "~/features/contentEntries/UnpublishEntry/index.js"; -import { GetUniqueFieldValuesUseCase } from "~/features/contentEntries/GetUniqueFieldValues/index.js"; +} from "~/features/contentEntry/ListEntries/index.js"; +import { ListEntriesUseCase } from "~/features/contentEntry/ListEntries/abstractions.js"; +import { GetEntriesByIdsUseCase } from "~/features/contentEntry/GetEntriesByIds/index.js"; +import { GetEntryByIdUseCase } from "~/features/contentEntry/GetEntryById/index.js"; +import { GetPublishedEntriesByIdsUseCase } from "~/features/contentEntry/GetPublishedEntriesByIds/index.js"; +import { GetLatestEntriesByIdsUseCase } from "~/features/contentEntry/GetLatestEntriesByIds/index.js"; +import { GetRevisionsByEntryIdUseCase } from "~/features/contentEntry/GetRevisionsByEntryId/index.js"; +import { GetEntryUseCase } from "~/features/contentEntry/GetEntry/index.js"; +import { DeleteEntryRevisionUseCase } from "~/features/contentEntry/DeleteEntryRevision/index.js"; +import { DeleteEntryUseCase } from "~/features/contentEntry/DeleteEntry/index.js"; +import { DeleteMultipleEntriesUseCase } from "~/features/contentEntry/DeleteMultipleEntries/abstractions.js"; +import { RestoreEntryFromBinUseCase } from "~/features/contentEntry/RestoreEntryFromBin/abstractions.js"; +import { UnpublishEntryUseCase } from "~/features/contentEntry/UnpublishEntry/index.js"; +import { GetUniqueFieldValuesUseCase } from "~/features/contentEntry/GetUniqueFieldValues/index.js"; interface CreateContentEntryCrudParams { context: CmsContext; diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntry.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntry.ts deleted file mode 100644 index be62c11c5d5..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntry.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; - -export interface IDeleteEntry { - execute: (model: CmsModel, id: string, params: CmsDeleteEntryOptions) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntryOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntryOperation.ts deleted file mode 100644 index 0cf0893c2b4..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IDeleteEntryOperation.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CmsEntryStorageOperationsDeleteParams, CmsModel } from "~/types/index.js"; - -export interface IDeleteEntryOperation { - execute: (model: CmsModel, options: CmsEntryStorageOperationsDeleteParams) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntriesByIds.ts deleted file mode 100644 index 1f1c948e11d..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntriesByIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetByIdsParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetEntriesByIds { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetByIdsParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntry.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntry.ts deleted file mode 100644 index 155b252d431..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetEntry.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CmsEntry, CmsEntryGetParams, CmsModel } from "~/types/index.js"; - -export interface IGetEntry { - execute: (model: CmsModel, params: CmsEntryGetParams) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestEntriesByIds.ts deleted file mode 100644 index 8ce9c1cd6a6..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestEntriesByIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetLatestByIdsParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetLatestEntriesByIds { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetLatestByIdsParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestRevisionByEntryId.ts deleted file mode 100644 index 86a192ce75a..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetLatestRevisionByEntryId.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetLatestRevisionParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetLatestRevisionByEntryId { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetLatestRevisionParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPreviousRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPreviousRevisionByEntryId.ts deleted file mode 100644 index 245ad06f3f2..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPreviousRevisionByEntryId.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetPreviousRevisionParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetPreviousRevisionByEntryId { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetPreviousRevisionParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedEntriesByIds.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedEntriesByIds.ts deleted file mode 100644 index a33fa0fb068..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedEntriesByIds.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetPublishedByIdsParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetPublishedEntriesByIds { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetPublishedByIdsParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedRevisionByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedRevisionByEntryId.ts deleted file mode 100644 index 808aedcdb29..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetPublishedRevisionByEntryId.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetPublishedRevisionParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetPublishedRevisionByEntryId { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetPublishedRevisionParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionById.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionById.ts deleted file mode 100644 index fe1ec03621c..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionById.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetRevisionParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetRevisionById { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetRevisionParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionsByEntryId.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionsByEntryId.ts deleted file mode 100644 index 86d23e2d3a8..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IGetRevisionsByEntryId.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsGetRevisionsParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IGetRevisionsByEntryId { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsGetRevisionsParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntries.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntries.ts deleted file mode 100644 index 6f4d711fe89..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntries.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { - CmsEntry, - CmsEntryListParams, - CmsEntryMeta, - CmsEntryValues, - CmsModel -} from "~/types/index.js"; - -export interface IListEntries { - execute: ( - model: CmsModel, - params?: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntriesOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntriesOperation.ts deleted file mode 100644 index 22f9a70f9fb..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IListEntriesOperation.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsListParams, - CmsEntryStorageOperationsListResponse, - CmsModel -} from "~/types/index.js"; - -export interface IListEntriesOperation { - execute: ( - model: CmsModel, - params: CmsEntryStorageOperationsListParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IMoveEntryToBinOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IMoveEntryToBinOperation.ts deleted file mode 100644 index 47ce099ce2b..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IMoveEntryToBinOperation.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CmsEntryStorageOperationsMoveToBinParams, CmsModel } from "~/types/index.js"; - -export interface IMoveEntryToBinOperation { - execute: (model: CmsModel, params: CmsEntryStorageOperationsMoveToBinParams) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBin.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBin.ts deleted file mode 100644 index c0b02f30bcb..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBin.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { CmsEntry, CmsModel } from "~/types/index.js"; - -export interface IRestoreEntryFromBin { - execute: (model: CmsModel, id: string) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBinOperation.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBinOperation.ts deleted file mode 100644 index 7f68cdd8600..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/IRestoreEntryFromBinOperation.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { - CmsEntryStorageOperationsRestoreFromBinParams, - CmsModel, - CmsStorageEntry -} from "~/types/index.js"; - -export interface IRestoreEntryFromBinOperation { - execute: ( - model: CmsModel, - options: CmsEntryStorageOperationsRestoreFromBinParams - ) => Promise; -} diff --git a/packages/api-headless-cms/src/crud/contentEntry/abstractions/index.ts b/packages/api-headless-cms/src/crud/contentEntry/abstractions/index.ts deleted file mode 100644 index def30341913..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/abstractions/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export type { IDeleteEntry } from "./IDeleteEntry.js"; -export type { IDeleteEntryOperation } from "./IDeleteEntryOperation.js"; -export type { IGetEntriesByIds } from "./IGetEntriesByIds.js"; -export type { IGetEntry } from "./IGetEntry.js"; -export type { IGetLatestEntriesByIds } from "./IGetLatestEntriesByIds.js"; -export type { IGetLatestRevisionByEntryId } from "./IGetLatestRevisionByEntryId.js"; -export type { IGetPreviousRevisionByEntryId } from "./IGetPreviousRevisionByEntryId.js"; -export type { IGetPublishedEntriesByIds } from "./IGetPublishedEntriesByIds.js"; -export type { IGetPublishedRevisionByEntryId } from "./IGetPublishedRevisionByEntryId.js"; -export type { IGetRevisionById } from "./IGetRevisionById.js"; -export type { IGetRevisionsByEntryId } from "./IGetRevisionsByEntryId.js"; -export type { IListEntries } from "./IListEntries.js"; -export type { IListEntriesOperation } from "./IListEntriesOperation.js"; -export type { IMoveEntryToBinOperation } from "./IMoveEntryToBinOperation.js"; -export type { IRestoreEntryFromBin } from "./IRestoreEntryFromBin.js"; -export type { IRestoreEntryFromBinOperation } from "./IRestoreEntryFromBinOperation.js"; diff --git a/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts b/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts index dcdced0c562..89eeeeef53e 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts @@ -18,7 +18,7 @@ interface MarkLockedFieldsParams { export const markLockedFields = async (params: MarkLockedFieldsParams): Promise => { const { model, context } = params; /** - * If the model is registered via a plugin, we don't need do process anything. + * If the model is registered via a plugin, we don't need to process anything. */ const plugins = context.plugins.byType(CmsModelPlugin.type); if (plugins.find(plugin => plugin.contentModel.modelId === model.modelId)) { diff --git a/packages/api-headless-cms/src/domains/contentEntries/errors.ts b/packages/api-headless-cms/src/domain/contentEntry/errors.ts similarity index 100% rename from packages/api-headless-cms/src/domains/contentEntries/errors.ts rename to packages/api-headless-cms/src/domain/contentEntry/errors.ts diff --git a/packages/api-headless-cms/src/domains/contentModels/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts similarity index 100% rename from packages/api-headless-cms/src/domains/contentModels/errors.ts rename to packages/api-headless-cms/src/domain/contentModel/errors.ts diff --git a/packages/api-headless-cms/src/domains/contentModelGroups/errors.ts b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts similarity index 100% rename from packages/api-headless-cms/src/domains/contentModelGroups/errors.ts rename to packages/api-headless-cms/src/domain/contentModelGroup/errors.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ContentEntriesFeature.ts rename to packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts index b77351c4300..e3d302cf37d 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { CreateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/CreateEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts index d293bb7b737..dadd03fa9b0 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; -import type { EntryStorageError, EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/events.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/events.ts index 934c40a344d..96050d8bab0 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/events.ts @@ -22,7 +22,7 @@ export interface EntryAfterCreatePayload { * EntryBeforeCreateEvent - Published before creating an entry */ export class EntryBeforeCreateEvent extends DomainEvent { - eventType = "entry.beforeCreate" as const; + eventType = "Cms/Entry/BeforeCreate" as const; getHandlerAbstraction() { return EntryBeforeCreateHandler; @@ -42,7 +42,7 @@ export namespace EntryBeforeCreateHandler { * EntryAfterCreateEvent - Published after creating an entry */ export class EntryAfterCreateEvent extends DomainEvent { - eventType = "entry.afterCreate" as const; + eventType = "Cms/Entry/AfterCreate" as const; getHandlerAbstraction() { return EntryAfterCreateHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts index c3dd1ca899b..84e42d0593f 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * CreateEntryRevisionFromRepository - Handles storage operations for creating entry revisions. diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts index 87039eb3242..61608f5dc70 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts @@ -6,8 +6,8 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { CreateEntryRevisionFromUseCase as UseCaseAbstraction } from "./abstractions.js"; import { CreateEntryRevisionFromRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; -import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; import type { CmsEntry, CmsModel, diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts index ee2d06ca639..aaffb359393 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts @@ -10,7 +10,7 @@ import type { EntryStorageError, EntryValidationError, EntryNotFoundError -} from "~/domains/contentEntries/errors.js"; +} from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts index 1ec4f476285..a0e4922545f 100644 --- a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts @@ -11,7 +11,7 @@ import type { * Before create entry revision event */ export class EntryRevisionBeforeCreateEvent extends DomainEvent { - eventType = "entry.revision.beforeCreate" as const; + eventType = "Cms/Entry/RevisionBeforeCreate" as const; getHandlerAbstraction() { return EntryRevisionBeforeCreateHandler; @@ -32,7 +32,7 @@ export namespace EntryRevisionBeforeCreateHandler { * After create entry revision event */ export class EntryRevisionAfterCreateEvent extends DomainEvent { - eventType = "entry.revision.afterCreate" as const; + eventType = "Cms/Entry/RevisionAfterCreate" as const; getHandlerAbstraction() { return EntryRevisionAfterCreateHandler; @@ -53,7 +53,7 @@ export namespace EntryRevisionAfterCreateHandler { * Create entry revision error event */ export class EntryRevisionCreateErrorEvent extends DomainEvent { - eventType = "entry.revision.createError" as const; + eventType = "Cms/Entry/RevisionCreateError" as const; getHandlerAbstraction() { return EntryRevisionCreateErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/CreateEntryRevisionFrom/index.ts rename to packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts similarity index 93% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts index 077e672c459..05c7d29b01f 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts index c28aa460aee..ccbd63279ca 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/DeleteEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryUseCase as UseCaseAbstraction, MoveEntryToBinUseCase } from "./abstractions.js"; import { DeleteEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts index 15609acfe3b..aff4950c356 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { MoveEntryToBinRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts index 9af92adcf0f..5e81e16c8e6 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/MoveEntryToBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts @@ -4,12 +4,12 @@ import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { MoveEntryToBinUseCase as UseCaseAbstraction } from "./abstractions.js"; import { MoveEntryToBinRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; import type { CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; import { ROOT_FOLDER } from "~/constants.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts index ac6e3c9c27b..f2a610b5124 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts index 81a84b58d5c..fd9e0f8f0d3 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/decorators/ForceDeleteDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts @@ -4,7 +4,7 @@ import { DeleteEntryUseCase } from "../abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; import { parseIdentifier } from "@webiny/utils"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * Handles force delete logic for cleanup scenarios. diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts index c7640ec2092..e3ef560e1cf 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts @@ -11,7 +11,7 @@ import type { * Before delete entry event */ export class EntryBeforeDeleteEvent extends DomainEvent { - eventType = "entry.beforeDelete" as const; + eventType = "Cms/Entry/BeforeDelete" as const; getHandlerAbstraction() { return EntryBeforeDeleteHandler; @@ -31,7 +31,7 @@ export namespace EntryBeforeDeleteHandler { * After delete entry event */ export class EntryAfterDeleteEvent extends DomainEvent { - eventType = "entry.afterDelete" as const; + eventType = "Cms/Entry/AfterDelete" as const; getHandlerAbstraction() { return EntryAfterDeleteHandler; @@ -51,7 +51,7 @@ export namespace EntryAfterDeleteHandler { * Delete entry error event */ export class EntryDeleteErrorEvent extends DomainEvent { - eventType = "entry.deleteError" as const; + eventType = "Cms/Entry/DeleteError" as const; getHandlerAbstraction() { return EntryDeleteErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts index a4a8df13d12..9776c248d67 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryRevisionRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index dd5af60f527..e3b11238dcb 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -3,10 +3,10 @@ import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryRevisionUseCase as UseCaseAbstraction } from "./abstractions.js"; import { DeleteEntryRevisionRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; -import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; -import { GetPreviousRevisionByEntryIdUseCase } from "~/features/contentEntries/GetPreviousRevisionByEntryId/index.js"; -import { DeleteEntryUseCase } from "~/features/contentEntries/DeleteEntry/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; +import { GetPreviousRevisionByEntryIdUseCase } from "~/features/contentEntry/GetPreviousRevisionByEntryId/index.js"; +import { DeleteEntryUseCase } from "~/features/contentEntry/DeleteEntry/index.js"; import type { CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts index 138ee5a90b2..6de34d80a96 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/events.ts index 5e29bfa35ca..def07e245f3 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/events.ts @@ -11,7 +11,7 @@ import type { * Before delete revision event */ export class EntryRevisionBeforeDeleteEvent extends DomainEvent { - eventType = "entryRevision.beforeDelete" as const; + eventType = "Cms/Entry/RevisionBeforeDelete" as const; getHandlerAbstraction() { return EntryRevisionBeforeDeleteHandler; @@ -31,7 +31,7 @@ export namespace EntryRevisionBeforeDeleteHandler { * After delete revision event */ export class EntryRevisionAfterDeleteEvent extends DomainEvent { - eventType = "entryRevision.afterDelete" as const; + eventType = "Cms/Entry/RevisionAfterDelete" as const; getHandlerAbstraction() { return EntryRevisionAfterDeleteHandler; @@ -51,7 +51,7 @@ export namespace EntryRevisionAfterDeleteHandler { * Delete revision error event */ export class EntryRevisionDeleteErrorEvent extends DomainEvent { - eventType = "entryRevision.deleteError" as const; + eventType = "Cms/Entry/RevisionDeleteError" as const; getHandlerAbstraction() { return EntryRevisionDeleteErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteEntryRevision/index.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts index e2914a6227b..ed90e9d2ebc 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { DeleteMultipleEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * DeleteMultipleEntriesRepository - Handles storage operations for deleting multiple entries. diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts index a8319f97949..24f92dc6044 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts @@ -4,7 +4,7 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { DeleteMultipleEntriesUseCase as UseCaseAbstraction } from "./abstractions.js"; import { DeleteMultipleEntriesRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { ListEntriesUseCase } from "~/features/contentEntries/ListEntries/abstractions.js"; +import { ListEntriesUseCase } from "~/features/contentEntry/ListEntries/abstractions.js"; import type { CmsModel } from "~/types/index.js"; import { EntryBeforeDeleteMultipleEvent, diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts index 25b0bf0df9d..448ba8ef251 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/events.ts index 61a887f6fa6..ccae7c42fa5 100644 --- a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/events.ts @@ -11,7 +11,7 @@ import type { * Before delete multiple entries event */ export class EntryBeforeDeleteMultipleEvent extends DomainEvent { - eventType = "entry.beforeDeleteMultiple" as const; + eventType = "Cms/Entry/BeforeDeleteMultiple" as const; getHandlerAbstraction() { return EntryBeforeDeleteMultipleHandler; @@ -31,7 +31,7 @@ export namespace EntryBeforeDeleteMultipleHandler { * After delete multiple entries event */ export class EntryAfterDeleteMultipleEvent extends DomainEvent { - eventType = "entry.afterDeleteMultiple" as const; + eventType = "Cms/Entry/AfterDeleteMultiple" as const; getHandlerAbstraction() { return EntryAfterDeleteMultipleHandler; @@ -51,7 +51,7 @@ export namespace EntryAfterDeleteMultipleHandler { * Delete multiple entries error event */ export class EntryDeleteMultipleErrorEvent extends DomainEvent { - eventType = "entry.deleteMultipleError" as const; + eventType = "Cms/Entry/DeleteMultipleError" as const; getHandlerAbstraction() { return EntryDeleteMultipleErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/DeleteMultipleEntries/index.ts rename to packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts index 6eb7e004777..5438f6399ab 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/GetEntriesByIdsUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts index df9a41583ed..ac30651fd0d 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/decorators/GetEntriesByIdsNotDeletedDecorator.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntriesByIds/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/GetEntryUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntry/GetEntryUseCase.ts index d34c24bf83f..30f95616a1d 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntry/GetEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntry/GetEntryUseCase.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { GetEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { ListEntriesUseCase } from "../ListEntries/abstractions.js"; import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * GetEntryUseCase - Gets a single entry by query. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts index 8d1baf8ae43..e6b81283010 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/GetEntryByIdUseCase.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntryById/GetEntryByIdUseCase.ts index 51724271aa2..2d7bb2b8988 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/GetEntryByIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/GetEntryByIdUseCase.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { GetEntryByIdUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetEntriesByIdsUseCase } from "../GetEntriesByIds/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * GetEntryByIdUseCase - Gets a single entry by ID. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts index e7a4e91baf9..ab612bda285 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntryById/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntryById/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetEntryById/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetEntryById/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts index 7389d7eb0a8..8765c632e3b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts index 998cf92f72e..c1deef9c6e8 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestEntriesByIds/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/BaseUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts index 3966670fa09..f438eac43a8 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts similarity index 99% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts index 7248d125760..033f13ccef4 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts @@ -6,7 +6,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts index d4a36254bde..beaf3a81ed9 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestDeletedRevisionByEntryIdUseCase.ts @@ -8,7 +8,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * Returns deleted entry only. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts index 556fd855565..5638356ec7c 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdUseCase.ts @@ -7,7 +7,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * Returns non-deleted entry only. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/BaseUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts index 4d93324a6be..734a86b8690 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPreviousRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts index 76aac91d42c..3783c9157c3 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdUseCase.ts @@ -7,7 +7,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * Returns non-deleted previous revision only. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts index 740e4071a0b..c491f55cbf9 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts @@ -6,7 +6,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPreviousRevisionByEntryId/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts index 6d5d32fa37e..460099b0687 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPublishedEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts index 5d42b2c30e3..eb0470a18fe 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedEntriesByIds/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts index 0122ac18fb0..740721de423 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPublishedRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts index a6d072ac6be..6737348c8c9 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts @@ -3,7 +3,7 @@ import { Result } from "@webiny/feature/api"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import type { CmsEntryStorageOperationsGetPublishedRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedRevisionByEntryId Use Case diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetPublishedRevisionByEntryId/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts index 0c57ba11c86..da0db216ba4 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetRevisionByIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError, EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/GetRevisionByIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts index 7104bccf9ad..98d7d368327 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionById Use Case - Fetches a specific entry revision by ID. diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts index a3dc6b24a90..7c81f44dbb6 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/decorators/GetRevisionByIdNotDeletedDecorator.ts @@ -1,7 +1,7 @@ import { createDecorator } from "@webiny/feature/api"; import { GetRevisionByIdUseCase } from "../abstractions.js"; import { Result } from "@webiny/feature/api"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionById/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionById/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts index d8b07d5acb7..0a8786a79f0 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetRevisionsByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts index eb78e261891..453e1fabbdd 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetRevisionsByEntryId/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts index d3656bd8121..9a1bba955ce 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts @@ -5,7 +5,7 @@ import { GetUniqueFieldValuesParams } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; class GetUniqueFieldValuesRepositoryImpl implements RepositoryAbstraction.Interface { diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts index 46ed54f9fe0..e20368ddf32 100644 --- a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; import type { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/errors.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/GetUniqueFieldValues/index.ts rename to packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListDeletedEntriesUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/ListDeletedEntriesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/ListDeletedEntriesUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts index d8e9ab7e4ae..6f9f7627fdb 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { ListEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryListParams, diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/ListEntriesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/ListLatestEntriesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListPublishedEntriesUseCase.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/ListPublishedEntriesUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/ListPublishedEntriesUseCase.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts index 9be0287a478..02f6dcdae8b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ListEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ListEntries/index.ts rename to packages/api-headless-cms/src/features/contentEntry/ListEntries/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts index 4da9cd1ef19..44d9ab0479b 100644 --- a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { MoveEntryRepository as RepositoryAbstraction } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * MoveEntryRepository - Handles storage operations for moving entries. diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts index f51287275cb..5bb4f6d12e1 100644 --- a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/MoveEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts @@ -4,11 +4,11 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { MoveEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { MoveEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforeMoveEvent, EntryAfterMoveEvent, EntryMoveErrorEvent } from "./events.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * MoveEntryUseCase - Orchestrates moving an entry to a different folder. diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts index 49b6aa3405a..2515e6d9954 100644 --- a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts @@ -1,9 +1,9 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; -import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * MoveEntry Use Case - Moves an entry to a different folder. diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts index b6609a1b17a..4196cd5880c 100644 --- a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts @@ -11,7 +11,7 @@ import type { * Before move entry event */ export class EntryBeforeMoveEvent extends DomainEvent { - eventType = "entry.beforeMove" as const; + eventType = "Cms/Entry/BeforeMove" as const; getHandlerAbstraction() { return EntryBeforeMoveHandler; @@ -31,7 +31,7 @@ export namespace EntryBeforeMoveHandler { * After move entry event */ export class EntryAfterMoveEvent extends DomainEvent { - eventType = "entry.afterMove" as const; + eventType = "Cms/Entry/AfterMove" as const; getHandlerAbstraction() { return EntryAfterMoveHandler; @@ -51,7 +51,7 @@ export namespace EntryAfterMoveHandler { * Move entry error event */ export class EntryMoveErrorEvent extends DomainEvent { - eventType = "entry.moveError" as const; + eventType = "Cms/Entry/MoveError" as const; getHandlerAbstraction() { return EntryMoveErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/MoveEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/MoveEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts index 013ff644f6c..1c9dc40a701 100644 --- a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * PublishEntryRepository - Handles storage operations for publishing entries. diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts index 31fc85ffca5..d41bf5617b3 100644 --- a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/PublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts @@ -5,8 +5,8 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { PublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { PublishEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; -import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; +import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforePublishEvent, @@ -14,7 +14,7 @@ import { EntryPublishErrorEvent } from "./events.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createPublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index 57c8b98e8c6..ea7e96c14b0 100644 --- a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -1,9 +1,9 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError, EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; -import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * PublishEntry Use Case - Publishes an entry revision. diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/events.ts index cd7872b1914..5179b932635 100644 --- a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/events.ts @@ -11,7 +11,7 @@ import type { * Before publish entry event */ export class EntryBeforePublishEvent extends DomainEvent { - eventType = "entry.beforePublish" as const; + eventType = "Cms/Entry/BeforePublish" as const; getHandlerAbstraction() { return EntryBeforePublishHandler; @@ -31,7 +31,7 @@ export namespace EntryBeforePublishHandler { * After publish entry event */ export class EntryAfterPublishEvent extends DomainEvent { - eventType = "entry.afterPublish" as const; + eventType = "Cms/Entry/AfterPublish" as const; getHandlerAbstraction() { return EntryAfterPublishHandler; @@ -51,7 +51,7 @@ export namespace EntryAfterPublishHandler { * Publish entry error event */ export class EntryPublishErrorEvent extends DomainEvent { - eventType = "entry.publishError" as const; + eventType = "Cms/Entry/PublishError" as const; getHandlerAbstraction() { return EntryPublishErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/PublishEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/PublishEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts index 9e819a100b0..22cdacf92c6 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * RepublishEntryRepository - Handles storage operations for republishing entries. diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts index 9eb7b495e3a..5b936192e5e 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/RepublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts @@ -5,7 +5,7 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { RepublishEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { RepublishEntryRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforeRepublishEvent, @@ -13,7 +13,7 @@ import { EntryRepublishErrorEvent } from "./events.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createRepublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts similarity index 93% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts index 710dcd468fd..4041c66d2dc 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts @@ -1,9 +1,9 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; -import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** * RepublishEntry Use Case - Republishes an already published entry. diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/events.ts index 07bb2f56def..b9fac5b15bf 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/events.ts @@ -11,7 +11,7 @@ import type { * Before republish entry event */ export class EntryBeforeRepublishEvent extends DomainEvent { - eventType = "entry.beforeRepublish" as const; + eventType = "Cms/Entry/BeforeRepublish" as const; getHandlerAbstraction() { return EntryBeforeRepublishHandler; @@ -31,7 +31,7 @@ export namespace EntryBeforeRepublishHandler { * After republish entry event */ export class EntryAfterRepublishEvent extends DomainEvent { - eventType = "entry.afterRepublish" as const; + eventType = "Cms/Entry/AfterRepublish" as const; getHandlerAbstraction() { return EntryAfterRepublishHandler; @@ -51,7 +51,7 @@ export namespace EntryAfterRepublishHandler { * Republish entry error event */ export class EntryRepublishErrorEvent extends DomainEvent { - eventType = "entry.republishError" as const; + eventType = "Cms/Entry/RepublishError" as const; getHandlerAbstraction() { return EntryRepublishErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/RepublishEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/RepublishEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts index c89711a0164..3710d509779 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; /** * RestoreEntryFromBinRepository - Handles storage operations for restoring entries from bin. diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts index 3b5810915d1..7b064dc17fc 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts @@ -5,7 +5,7 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { RestoreEntryFromBinUseCase as UseCaseAbstraction } from "./abstractions.js"; import { RestoreEntryFromBinRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetLatestDeletedRevisionByEntryIdUseCase } from "~/features/contentEntries/GetLatestRevisionByEntryId/index.js"; +import { GetLatestDeletedRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLatestRevisionByEntryId/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforeRestoreFromBinEvent, @@ -13,7 +13,7 @@ import { EntryRestoreFromBinErrorEvent } from "./events.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts index 34b0d875cf6..39471ef8e65 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts similarity index 92% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts index e9b73a89e8e..59337d7b4a0 100644 --- a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts @@ -11,7 +11,7 @@ import type { * Before restore entry from bin event */ export class EntryBeforeRestoreFromBinEvent extends DomainEvent { - eventType = "entry.beforeRestoreFromBin" as const; + eventType = "Cms/Entry/BeforeRestoreFromBin" as const; getHandlerAbstraction() { return EntryBeforeRestoreFromBinHandler; @@ -32,7 +32,7 @@ export namespace EntryBeforeRestoreFromBinHandler { * After restore entry from bin event */ export class EntryAfterRestoreFromBinEvent extends DomainEvent { - eventType = "entry.afterRestoreFromBin" as const; + eventType = "Cms/Entry/AfterRestoreFromBin" as const; getHandlerAbstraction() { return EntryAfterRestoreFromBinHandler; @@ -53,7 +53,7 @@ export namespace EntryAfterRestoreFromBinHandler { * Restore entry from bin error event */ export class EntryRestoreFromBinErrorEvent extends DomainEvent { - eventType = "entry.restoreFromBinError" as const; + eventType = "Cms/Entry/RestoreFromBinError" as const; getHandlerAbstraction() { return EntryRestoreFromBinErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/RestoreEntryFromBin/index.ts rename to packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts index dc7db9b78e9..1dd6101fe60 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { UnpublishEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts index a36c0e18aef..4090d121213 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/UnpublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts @@ -9,12 +9,12 @@ import { EntryBeforeUnpublishEvent } from "./events.js"; import { EntryAfterUnpublishEvent } from "./events.js"; import { EntryUnpublishErrorEvent } from "./events.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetPublishedRevisionByEntryIdUseCase } from "~/features/contentEntries/GetPublishedRevisionByEntryId/index.js"; +import { GetPublishedRevisionByEntryIdUseCase } from "~/features/contentEntry/GetPublishedRevisionByEntryId/index.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; -import { EntryValidationError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; +import { EntryValidationError } from "~/domain/contentEntry/errors.js"; import { createUnpublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts similarity index 88% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts index fd4272e3aea..408252adfe8 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts @@ -2,9 +2,9 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; -import type { EntryStorageError } from "~/domains/contentEntries/errors.js"; -import type { EntryValidationError } from "~/domains/contentEntries/errors.js"; +import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; +import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/events.ts similarity index 93% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/events.ts index 8711a665c6f..eff5fd9a183 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/events.ts @@ -28,7 +28,7 @@ export interface EntryUnpublishErrorPayload { * EntryBeforeUnpublishEvent - Published before unpublishing an entry */ export class EntryBeforeUnpublishEvent extends DomainEvent { - eventType = "entry.beforeUnpublish" as const; + eventType = "Cms/Entry/BeforeUnpublish" as const; getHandlerAbstraction() { return EntryBeforeUnpublishHandler; @@ -48,7 +48,7 @@ export namespace EntryBeforeUnpublishHandler { * EntryAfterUnpublishEvent - Published after unpublishing an entry */ export class EntryAfterUnpublishEvent extends DomainEvent { - eventType = "entry.afterUnpublish" as const; + eventType = "Cms/Entry/AfterUnpublish" as const; getHandlerAbstraction() { return EntryAfterUnpublishHandler; @@ -68,7 +68,7 @@ export namespace EntryAfterUnpublishHandler { * EntryUnpublishErrorEvent - Published when unpublish fails */ export class EntryUnpublishErrorEvent extends DomainEvent { - eventType = "entry.unpublishError" as const; + eventType = "Cms/Entry/UnpublishError" as const; getHandlerAbstraction() { return EntryUnpublishErrorHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/UnpublishEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts similarity index 95% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts index 5bcada5f6e3..02628dda4f4 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { UpdateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domains/contentEntries/errors.js"; +import { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts index 3bce150b631..54c0d83073e 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/UpdateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts @@ -8,7 +8,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { CmsContext } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/abstractions.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/abstractions.js"; import type { CmsEntry, CmsModel, @@ -17,7 +17,7 @@ import type { } from "~/types/index.js"; import type { GenericRecord } from "@webiny/api/types.js"; import { NotAuthorizedError } from "~/utils/errors.js"; -import { EntryLockedError } from "~/domains/contentEntries/errors.js"; +import { EntryLockedError } from "~/domain/contentEntry/errors.js"; import { createUpdateEntryData } from "~/crud/contentEntry/entryDataFactories/createUpdateEntryData.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts similarity index 97% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts index 3454de4f4e9..dd5241eddd9 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts @@ -12,7 +12,7 @@ import type { EntryStorageError, EntryValidationError, EntryLockedError -} from "~/domains/contentEntries/errors.js"; +} from "~/domain/contentEntry/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts similarity index 94% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts index 7fdda7c169b..4b835d093ad 100644 --- a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts @@ -24,7 +24,7 @@ export interface EntryAfterUpdatePayload { * EntryBeforeUpdateEvent - Published before updating an entry */ export class EntryBeforeUpdateEvent extends DomainEvent { - eventType = "entry.beforeUpdate" as const; + eventType = "Cms/Entry/BeforeUpdate" as const; getHandlerAbstraction() { return EntryBeforeUpdateHandler; @@ -44,7 +44,7 @@ export namespace EntryBeforeUpdateHandler { * EntryAfterUpdateEvent - Published after updating an entry */ export class EntryAfterUpdateEvent extends DomainEvent { - eventType = "entry.afterUpdate" as const; + eventType = "Cms/Entry/AfterUpdate" as const; getHandlerAbstraction() { return EntryAfterUpdateHandler; diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/UpdateEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/UpdateEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts rename to packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts index a4400ed1110..9067bf40429 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/ValidateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts @@ -2,7 +2,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { ValidateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; import { NotAuthorizedError } from "~/utils/errors.js"; import { mapAndCleanUpdatedInputData } from "~/crud/contentEntry/entryDataFactories/index.js"; diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts similarity index 86% rename from packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts rename to packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts index 22283daa8ee..1a655f540d7 100644 --- a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts @@ -2,8 +2,8 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; -import type { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; -import { GetRevisionByIdUseCase } from "~/features/contentEntries/GetRevisionById/index.js"; +import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; +import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; /** * ValidateEntry Use Case - Validates entry data against model field validators. diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/feature.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ValidateEntry/feature.ts rename to packages/api-headless-cms/src/features/contentEntry/ValidateEntry/feature.ts diff --git a/packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/index.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentEntries/ValidateEntry/index.ts rename to packages/api-headless-cms/src/features/contentEntry/ValidateEntry/index.ts diff --git a/packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts similarity index 99% rename from packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts rename to packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts index 17ff525347d..3ce2a35d21e 100644 --- a/packages/api-headless-cms/src/features/contentModels/shared/ModelsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts @@ -8,7 +8,7 @@ import { ModelStorageError, ModelCannotUpdateCodeDefinedError, ModelCannotDeleteCodeDefinedError -} from "~/domains/contentModels/errors.js"; +} from "~/domain/contentModel/errors.js"; import type { CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts b/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentModels/shared/PluginModelsProvider.ts rename to packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts diff --git a/packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts rename to packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index 7e6dda1e879..b580eb5f5fc 100644 --- a/packages/api-headless-cms/src/features/contentModels/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -6,7 +6,7 @@ import type { ModelStorageError, ModelCannotUpdateCodeDefinedError, ModelCannotDeleteCodeDefinedError -} from "~/domains/contentModels/errors.js"; +} from "~/domain/contentModel/errors.js"; export interface IModelsRepositoryErrors { base: diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts similarity index 99% rename from packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts rename to packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts index e9ac290cbea..d22a2bfaf0c 100644 --- a/packages/api-headless-cms/src/features/contentModelGroups/shared/GroupsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts @@ -7,7 +7,7 @@ import { GroupStorageError, GroupCannotUpdateCodeDefinedError, GroupCannotDeleteCodeDefinedError -} from "~/domains/contentModelGroups/errors.js"; +} from "~/domain/contentModelGroup/errors.js"; import type { CmsGroup } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts similarity index 100% rename from packages/api-headless-cms/src/features/contentModelGroups/shared/PluginGroupsProvider.ts rename to packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts diff --git a/packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts similarity index 98% rename from packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts rename to packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts index 2f2357669c0..9ec2368a936 100644 --- a/packages/api-headless-cms/src/features/contentModelGroups/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts @@ -6,7 +6,7 @@ import type { GroupStorageError, GroupCannotUpdateCodeDefinedError, GroupCannotDeleteCodeDefinedError -} from "~/domains/contentModelGroups/errors.js"; +} from "~/domain/contentModelGroup/errors.js"; export interface IGroupsRepositoryErrors { base: diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts index 650eacb5e5b..c009b118604 100644 --- a/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/read/resolveGet.ts @@ -3,7 +3,7 @@ import type { CmsEntryListParams, CmsEntryResolverFactory as ResolverFactory } from "~/types/index.js"; -import { EntryNotFoundError } from "~/domains/contentEntries/errors.js"; +import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; type ResolveGet = ResolverFactory; From ca96a7aa698db02267d445b4f09d135a6341bb7a Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 11:59:19 +0100 Subject: [PATCH 11/71] wip: migrate content group model domain --- .../contentAPI/contentModelGroup.crud.test.ts | 16 +- .../contentModelGroup/shared/GroupCache.ts | 1 + packages/api-headless-cms/src/context.ts | 2 + .../src/crud/contentModelGroup.crud.ts | 17 +-- .../ContentModelGroupFeature.ts | 18 +++ .../GetGroup/GetGroupRepository.ts | 101 +++++++++++++ .../GetGroup/GetGroupUseCase.ts | 46 ++++++ .../GetGroup/abstractions.ts | 47 ++++++ .../contentModelGroup/GetGroup/feature.ts | 20 +++ .../contentModelGroup/GetGroup/index.ts | 1 + .../contentModelGroup/shared/GroupCache.ts | 31 ++++ .../shared/GroupsRepository.ts | 143 ------------------ .../shared/PluginGroupsProvider.ts | 75 +++++++-- .../contentModelGroup/shared/abstractions.ts | 90 +++-------- .../src/utils/caching/types.ts | 2 +- 15 files changed, 367 insertions(+), 243 deletions(-) create mode 100644 packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/GetGroup/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/GetGroup/index.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts delete mode 100644 packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts index b2f29b7cfc9..5a41be4f150 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts @@ -54,7 +54,7 @@ const createPermissions = (groups: string[]) => [ } ]; -describe("Cms Group crud test", () => { +describe("Group crud test", () => { const { getContentModelGroupQuery, listContentModelGroupsQuery, @@ -187,8 +187,8 @@ describe("Cms Group crud test", () => { getContentModelGroup: { data: null, error: { - message: `Cms Group "nonExistingId" was not found!`, - code: "NOT_FOUND", + message: `Group "nonExistingId" was not found!`, + code: "GROUP_NOT_FOUND", data: null } } @@ -209,8 +209,8 @@ describe("Cms Group crud test", () => { updateContentModelGroup: { data: null, error: { - message: `Cms Group "nonExistingIdUpdate" was not found!`, - code: "NOT_FOUND", + message: `Group "nonExistingIdUpdate" was not found!`, + code: "GROUP_NOT_FOUND", data: null } } @@ -227,8 +227,8 @@ describe("Cms Group crud test", () => { deleteContentModelGroup: { data: null, error: { - message: `Cms Group "nonExistingIdDelete" was not found!`, - code: "NOT_FOUND", + message: `Group "nonExistingIdDelete" was not found!`, + code: "GROUP_NOT_FOUND", data: null } } @@ -339,7 +339,7 @@ describe("Cms Group crud test", () => { createContentModelGroup: { data: null, error: { - message: `Cms Group with the slug "content-model-group" already exists.`, + message: `Group with the slug "content-model-group" already exists.`, code: "SLUG_ALREADY_EXISTS", data: expect.any(Object) } diff --git a/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts b/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts new file mode 100644 index 00000000000..9daeafb9864 --- /dev/null +++ b/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts @@ -0,0 +1 @@ +test diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index 53d4fe494ee..90c1edb1bbc 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -30,6 +30,7 @@ import { } from "./legacy/abstractions.js"; import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; +import { ContentModelGroupFeature } from "~/features/contentModelGroup/ContentModelGroupFeature.js"; const getParameters = async (context: CmsContext): Promise => { const plugins = context.plugins.byType(CmsParametersPlugin.type); @@ -176,6 +177,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { // Register features CmsInstallerFeature.register(context.container, context.cms); ContentEntriesFeature.register(context.container); + ContentModelGroupFeature.register(context.container); if (!storageOperations.init) { return; diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index 54b23d67cf5..f01fd9c2139 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -31,6 +31,7 @@ import { listGroupsFromDatabase } from "~/crud/contentModelGroup/listGroupsFromD import type { AccessControl } from "./AccessControl/AccessControl.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; export interface CreateModelGroupsCrudParams { getTenant: () => Tenant; @@ -171,19 +172,15 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG * CRUD Methods */ const getGroup: CmsGroupContext["getGroup"] = async id => { - await accessControl.ensureCanAccessGroup(); + const useCase = context.container.resolve(GetGroupUseCase); + const result = await useCase.execute(id); - const groups = await context.security.withoutAuthorization(async () => { - return fetchGroups(getTenant().id); - }); - const group = groups.find(group => group.id === id); - if (!group) { - throw new NotFoundError(`Cms Group "${id}" was not found!`); + if (result.isFail()) { + const error = result.error; + throw new WebinyError(error.message, error.code, error.data); } - await accessControl.ensureCanAccessGroup({ group }); - - return group; + return result.value; }; const listGroups: CmsGroupContext["listGroups"] = async params => { diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts new file mode 100644 index 00000000000..c320e378d0a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -0,0 +1,18 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetGroupFeature } from "~/features/contentModelGroup/GetGroup/feature.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; + +export const ContentModelGroupFeature = createFeature({ + name: "ContentModelGroup", + register(container) { + // Shared infrastructure (singletons) + container.register(GroupCache).inSingletonScope(); + container.register(PluginGroupsProvider).inSingletonScope(); + + // Query features + GetGroupFeature.register(container); + + // Command features + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts new file mode 100644 index 00000000000..c449aec8199 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts @@ -0,0 +1,101 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetGroupRepository as RepositoryAbstraction } from "./abstractions.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; +import { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * GetGroupRepository - Fetches a single group by ID. + * + * Responsibilities: + * - Create cache keys based on tenant + identity + * - Provide data loader functions to GroupCache + * - Fetch from plugin groups + database groups + * - Apply access control filtering + * - Return the group or NotFoundError + */ +class GetGroupRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private groupCache: GroupCache.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute(groupId: string): Promise> { + const tenant = this.tenantContext.getTenant(); + + // Fetch all groups (plugin + database) with access control filtering + const groups = await this.fetchAllGroups(tenant.id); + + const group = groups.find(g => g.id === groupId); + + if (!group) { + return Result.fail(new GroupNotFoundError(groupId)); + } + + return Result.ok(group); + } + + private async fetchAllGroups(tenant: string): Promise { + // 1. Fetch plugin groups (with caching and access control) + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + + // 2. Fetch database groups (with caching) + const dbCacheKey = createCacheKey({ tenant }); + const databaseGroups = await this.groupCache.getOrSet(dbCacheKey, async () => { + return await this.storageOperations.groups.list({ + where: { tenant } + }); + }); + + // 3. Apply access control to database groups (with caching) + const filteredCacheKey = createCacheKey({ + dbCacheKey: dbCacheKey.get(), + identity: this.cmsContext.security.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined + }); + + const filteredDatabaseGroups = await this.groupCache.getOrSet( + filteredCacheKey, + async () => { + return filterAsync(databaseGroups, async (group?: CmsGroup) => { + if (!group) { + return false; + } + return this.accessControl.canAccessGroup({ group }); + }); + } + ); + + // 4. Merge plugin + database groups + return [...pluginGroups, ...filteredDatabaseGroups]; + } +} + +export const GetGroupRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: GetGroupRepositoryImpl, + dependencies: [ + GroupCache, + PluginGroupsProvider, + StorageOperations, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts new file mode 100644 index 00000000000..17a0f7bc0fc --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts @@ -0,0 +1,46 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { GetGroupUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetGroupRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * GetGroupUseCase - Retrieves a single content model group by ID. + * + * Responsibilities: + * - Apply initial access control check + * - Delegate to repository (which uses GroupCache for plugin + DB groups) + * - Return the group or appropriate error + */ +class GetGroupUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetGroupRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(groupId: string): Promise> { + // Initial access control check (no specific group yet) + const canAccess = await this.accessControl.canAccessGroup(); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Repository uses GroupCache to fetch all groups, then filters by ID + // GroupCache handles merging plugin + database groups and access control + const result = await this.repository.execute(groupId); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const GetGroupUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: GetGroupUseCaseImpl, + dependencies: [GetGroupRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts new file mode 100644 index 00000000000..a34f4a4e472 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts @@ -0,0 +1,47 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * GetGroup Use Case + */ +export interface IGetGroupUseCase { + execute(groupId: string): Promise>; +} + +export interface IGetGroupUseCaseErrors { + notFound: GroupNotFoundError; + notAuthorized: NotAuthorizedError; + repository: RepositoryError; +} + +type UseCaseError = IGetGroupUseCaseErrors[keyof IGetGroupUseCaseErrors]; + +export const GetGroupUseCase = createAbstraction("GetGroupUseCase"); + +export namespace GetGroupUseCase { + export type Interface = IGetGroupUseCase; + export type Error = UseCaseError; +} + +/** + * GetGroupRepository - Fetches a single group by ID from cache. + */ +export interface IGetGroupRepository { + execute(groupId: string): Promise>; +} + +export interface IGetGroupRepositoryErrors { + notFound: GroupNotFoundError; +} + +type RepositoryError = IGetGroupRepositoryErrors[keyof IGetGroupRepositoryErrors]; + +export const GetGroupRepository = createAbstraction("GetGroupRepository"); + +export namespace GetGroupRepository { + export type Interface = IGetGroupRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/feature.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/feature.ts new file mode 100644 index 00000000000..ad8559fb8b2 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetGroupUseCase } from "./GetGroupUseCase.js"; +import { GetGroupRepository } from "./GetGroupRepository.js"; + +/** + * GetGroup Feature + * + * Provides functionality for retrieving a single content model group by ID. + * Uses GroupCache (registered elsewhere) for fetching groups. + */ +export const GetGroupFeature = createFeature({ + name: "GetGroup", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(GetGroupUseCase); + + // Register repository in singleton scope (shared instance) + container.register(GetGroupRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/index.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts new file mode 100644 index 00000000000..aab28475690 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts @@ -0,0 +1,31 @@ +import { createImplementation } from "@webiny/feature/api"; +import { GroupCache as GroupCacheAbstraction } from "./abstractions.js"; +import { createMemoryCache } from "~/utils/index.js"; +import type { ICacheKey } from "~/utils/caching/types.js"; + +/** + * GroupCache implementation + * + * A simple promise deduplication cache that prevents duplicate concurrent requests. + * Repositories are responsible for: + * - Creating cache keys + * - Providing data loader functions + * - Implementing the actual data fetching logic + */ +class GroupCacheImpl implements GroupCacheAbstraction.Interface { + private cache = createMemoryCache>(); + + getOrSet(cacheKey: ICacheKey, loader: () => Promise): Promise { + return this.cache.getOrSet(cacheKey, loader); + } + + clear(): void { + this.cache.clear(); + } +} + +export const GroupCache = createImplementation({ + abstraction: GroupCacheAbstraction, + implementation: GroupCacheImpl, + dependencies: [] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts deleted file mode 100644 index d22a2bfaf0c..00000000000 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupsRepository.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Result } from "@webiny/feature/api"; -import { createImplementation } from "@webiny/feature/api"; -import { GroupsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { PluginGroupsProvider } from "./abstractions.js"; -import { - GroupNotFoundError, - GroupStorageError, - GroupCannotUpdateCodeDefinedError, - GroupCannotDeleteCodeDefinedError -} from "~/domain/contentModelGroup/errors.js"; -import type { CmsGroup } from "~/types/index.js"; -import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; -import { TenantContext } from "@webiny/api-core/features/TenantContext"; - -/** - * GroupsRepository implementation following CQS principle. - * Provides unified access to both database-stored and plugin-defined groups. - */ -class GroupsRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private tenantContext: TenantContext.Interface, - private pluginGroupsProvider: PluginGroupsProvider.Interface, - private accessControl: AccessControl.Interface, - private storageOperations: StorageOperations.Interface - ) {} - - async get(groupId: string): Promise> { - try { - // 1. Check plugin groups first (code-defined, immutable) - const pluginGroups = await this.pluginGroupsProvider.getGroups(); - const pluginGroup = pluginGroups.find(g => g.id === groupId); - - if (pluginGroup) { - // Apply access control - const canAccess = await this.accessControl.canAccessGroup({ group: pluginGroup }); - if (!canAccess) { - return Result.fail(new GroupNotFoundError(groupId)); - } - return Result.ok(pluginGroup); - } - - // 2. Query database groups - const tenant = this.tenantContext.getTenant(); - const dbGroup = await this.storageOperations.groups.get({ - id: groupId, - tenant: tenant.id - }); - if (!dbGroup) { - return Result.fail(new GroupNotFoundError(groupId)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessGroup({ group: dbGroup }); - if (!canAccess) { - return Result.fail(new GroupNotFoundError(groupId)); - } - - return Result.ok(dbGroup); - } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); - } - } - - async list(): Promise> { - try { - // 1. Get plugin groups - const pluginGroups = await this.pluginGroupsProvider.getGroups(); - - // 2. Get DB groups - const tenant = this.tenantContext.getTenant(); - const dbGroups = await this.storageOperations.groups.list({ - where: { tenant: tenant.id } - }); - - // 3. Combine both sources - const allGroups = [...pluginGroups, ...dbGroups]; - - // 4. Apply access control to all groups - const accessibleGroups: CmsGroup[] = []; - for (const group of allGroups) { - const canAccess = await this.accessControl.canAccessGroup({ group }); - if (canAccess) { - accessibleGroups.push(group); - } - } - - return Result.ok(accessibleGroups); - } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); - } - } - - async create(group: CmsGroup): Promise> { - try { - // Only DB groups can be created (plugin groups are code-defined) - await this.storageOperations.groups.create({ group }); - return Result.ok(); - } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); - } - } - - async update(group: CmsGroup): Promise> { - try { - // Cannot update plugin groups - const pluginGroups = await this.pluginGroupsProvider.getGroups(); - const isPluginGroup = pluginGroups.some(g => g.id === group.id); - - if (isPluginGroup) { - return Result.fail(new GroupCannotUpdateCodeDefinedError(group.id)); - } - - await this.storageOperations.groups.update({ group }); - return Result.ok(); - } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); - } - } - - async delete(group: CmsGroup): Promise> { - try { - // Cannot delete plugin groups - const pluginGroups = await this.pluginGroupsProvider.getGroups(); - const isPluginGroup = pluginGroups.some(g => g.id === group.id); - - if (isPluginGroup) { - return Result.fail(new GroupCannotDeleteCodeDefinedError(group.id)); - } - - await this.storageOperations.groups.delete({ group }); - return Result.ok(); - } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); - } - } -} - -export const GroupsRepository = createImplementation({ - abstraction: RepositoryAbstraction, - implementation: GroupsRepositoryImpl, - dependencies: [TenantContext, PluginGroupsProvider, AccessControl, StorageOperations] -}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts index 3e886342c88..8071a5b8f6e 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts @@ -1,35 +1,80 @@ +import { createImplementation } from "@webiny/feature/api"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { PluginGroupsProvider as ProviderAbstraction } from "./abstractions.js"; +import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import { createMemoryCache } from "~/utils/index.js"; import type { CmsGroup } from "~/types/index.js"; -export type GetGroups = () => CmsGroup[]; - /** - * PluginGroupsProvider implementation that fetches groups from plugins. + * PluginGroupsProvider implementation + * + * Fetches groups from CmsGroupPlugin instances. + * Filters by tenant and applies access control. + * Results are cached based on tenant + identity + plugin signatures. */ -export class PluginGroupsProvider implements ProviderAbstraction.Interface { +class PluginGroupsProviderImpl implements ProviderAbstraction.Interface { + private cache = createMemoryCache>(); + constructor( private tenantContext: TenantContext.Interface, - private getCmsGroups: GetGroups + private cmsContext: CmsContext.Interface, + private accessControl: AccessControl.Interface, + private identityContext: IdentityContext.Interface ) {} async getGroups(): Promise { const tenant = this.tenantContext.getTenant(); + const pluginGroups = this.cmsContext.plugins.byType(CmsGroupPlugin.type); - const groups = this.getCmsGroups(); + const cacheKey = createCacheKey({ + tenant: tenant.id, + identity: this.cmsContext.security.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined, + groups: pluginGroups + .map(({ contentModelGroup: group }) => { + return `${group.id}#${group.slug}#${group.savedOn || "unknown"}`; + }) + .join("/") + }); - return groups - .filter(group => { + return this.cache.getOrSet(cacheKey, async (): Promise => { + const groups = pluginGroups // Filter by tenant if specified in plugin // If not specified, plugin group is available for all tenants/locales - if (group.tenant && group.tenant !== tenant.id) { + .filter(plugin => { + const { tenant: t } = plugin.contentModelGroup; + if (t && t !== tenant.id) { + return false; + } + return true; + }) + .map(plugin => { + return { + ...plugin.contentModelGroup, + tenant: tenant.id, + webinyVersion: this.cmsContext.WEBINY_VERSION + }; + }); + + // Apply access control filtering + return filterAsync(groups, async (group?: CmsGroup) => { + if (!group) { return false; } - return true; - }) - .map(group => ({ - ...group, - tenant: tenant.id - })) as CmsGroup[]; + return this.accessControl.canAccessGroup({ group }); + }); + }); } } + +export const PluginGroupsProvider = createImplementation({ + abstraction: ProviderAbstraction, + implementation: PluginGroupsProviderImpl, + dependencies: [TenantContext, CmsContext, AccessControl, IdentityContext] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts index 9ec2368a936..6a67eff9e9b 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts @@ -1,71 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; -import type { - GroupNotFoundError, - GroupStorageError, - GroupCannotUpdateCodeDefinedError, - GroupCannotDeleteCodeDefinedError -} from "~/domain/contentModelGroup/errors.js"; - -export interface IGroupsRepositoryErrors { - base: - | GroupNotFoundError - | GroupStorageError - | GroupCannotUpdateCodeDefinedError - | GroupCannotDeleteCodeDefinedError; -} - -type RepositoryError = IGroupsRepositoryErrors[keyof IGroupsRepositoryErrors]; - -/** - * GroupsRepository follows CQS (Command-Query Separation): - * - Queries (get, list): Return data wrapped in Result - * - Commands (create, update, delete): Return Result - * - * This repository provides unified access to both database-stored groups - * and plugin-defined (code) groups, transparently handling access control. - */ -export interface IGroupsRepository { - /** - * Get a single group by ID. - * Checks plugin groups first, then database groups. - * Applies access control. - */ - get(groupId: string): Promise>; - - /** - * List all accessible groups. - * Combines plugin groups and database groups. - * Applies access control to all results. - */ - list(): Promise>; - - /** - * Create a new group in the database. - * Plugin groups cannot be created (they are code-defined). - */ - create(group: CmsGroup): Promise>; - - /** - * Update an existing database group. - * Plugin groups cannot be updated. - */ - update(group: CmsGroup): Promise>; - - /** - * Delete a database group. - * Plugin groups cannot be deleted. - */ - delete(group: CmsGroup): Promise>; -} - -export const GroupsRepository = createAbstraction("GroupsRepository"); - -export namespace GroupsRepository { - export type Interface = IGroupsRepository; - export type Error = RepositoryError; -} +import type { ICacheKey } from "~/utils/caching/types.js"; /** * PluginGroupsProvider provides access to plugin-defined (code) groups. @@ -81,3 +16,26 @@ export const PluginGroupsProvider = createAbstraction( export namespace PluginGroupsProvider { export type Interface = IPluginGroupsProvider; } + +/** + * GroupCache abstraction - Simple promise deduplication cache + */ +export interface IGroupCache { + /** + * Get or set a value in the cache using a loader function. + * If a promise is already pending for this key, returns the existing promise. + * Otherwise, executes the loader and caches the promise. + */ + getOrSet(cacheKey: ICacheKey, loader: () => Promise): Promise; + + /** + * Clear all cached promises. Should be called after create/update/delete operations. + */ + clear(): void; +} + +export const GroupCache = createAbstraction("GroupCache"); + +export namespace GroupCache { + export type Interface = IGroupCache; +} diff --git a/packages/api-headless-cms/src/utils/caching/types.ts b/packages/api-headless-cms/src/utils/caching/types.ts index 93262d7e518..8b578cfb19e 100644 --- a/packages/api-headless-cms/src/utils/caching/types.ts +++ b/packages/api-headless-cms/src/utils/caching/types.ts @@ -1,6 +1,6 @@ import { ICacheKeyKeys } from "@webiny/utils"; -export { ICacheKeyKeys }; +export type { ICacheKeyKeys }; export interface ICacheKey { get(): string; From 237eb82d8a66f0e6a15b48e6a1eb7597f08c305d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 12:19:53 +0100 Subject: [PATCH 12/71] wip: migrate content group model domain --- .../contentAPI/contentModelGroup.crud.test.ts | 5 +- .../src/crud/contentModelGroup.crud.ts | 12 ++- .../ContentModelGroupFeature.ts | 2 + .../GetGroup/GetGroupRepository.ts | 21 ++-- .../GetGroup/abstractions.ts | 2 + .../ListGroups/ListGroupsRepository.ts | 99 +++++++++++++++++++ .../ListGroups/ListGroupsUseCase.ts | 46 +++++++++ .../ListGroups/abstractions.ts | 48 +++++++++ .../contentModelGroup/ListGroups/feature.ts | 20 ++++ .../contentModelGroup/ListGroups/index.ts | 1 + 10 files changed, 242 insertions(+), 14 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ListGroups/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/ListGroups/index.ts diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts index 5a41be4f150..0f4a23a6b99 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { identity } from "../testHelpers/helpers"; import { toSlug } from "~/utils/toSlug"; import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; +import * as readline from "node:readline"; enum TestHelperEnum { MODELS_AMOUNT = 3, @@ -61,7 +62,7 @@ describe("Group crud test", () => { createContentModelGroupMutation, updateContentModelGroupMutation, deleteContentModelGroupMutation - } = useGraphQLHandler({ path: "manage/en-us" }); + } = useGraphQLHandler({ path: "manage" }); it("content model group create, read, update, delete and list all at once", async () => { const updatedContentModelGroups = []; @@ -381,7 +382,7 @@ describe("Group crud test", () => { // Create listGroups query with permission for only specific groups const { listContentModelGroupsQuery: listGroups } = useGraphQLHandler({ - path: "manage/en-us", + path: "manage", permissions: createPermissions([groups[0]]) }); diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index f01fd9c2139..3bc22970729 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -32,6 +32,7 @@ import type { AccessControl } from "./AccessControl/AccessControl.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; +import { ListGroupsUseCase } from "~/features/contentModelGroup/ListGroups/index.js"; export interface CreateModelGroupsCrudParams { getTenant: () => Tenant; @@ -184,13 +185,16 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG }; const listGroups: CmsGroupContext["listGroups"] = async params => { - const { where } = params || {}; + const useCase = context.container.resolve(ListGroupsUseCase); + const result = await useCase.execute(); - const { tenant } = where || {}; + if (result.isFail()) { + const error = result.error; - await accessControl.ensureCanAccessGroup(); + throw new WebinyError(error.message, error.code, error.data); + } - return fetchGroups(tenant || getTenant().id); + return result.value; }; const createGroup: CmsGroupContext["createGroup"] = async input => { diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts index c320e378d0a..c2b9cde5c05 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -1,5 +1,6 @@ import { createFeature } from "@webiny/feature/api"; import { GetGroupFeature } from "~/features/contentModelGroup/GetGroup/feature.js"; +import { ListGroupsFeature } from "~/features/contentModelGroup/ListGroups/feature.js"; import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; @@ -12,6 +13,7 @@ export const ContentModelGroupFeature = createFeature({ // Query features GetGroupFeature.register(container); + ListGroupsFeature.register(container); // Command features } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts index c449aec8199..699be35e024 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts @@ -6,6 +6,7 @@ import { GetGroupRepository as RepositoryAbstraction } from "./abstractions.js"; import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; import { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -35,18 +36,22 @@ class GetGroupRepositoryImpl implements RepositoryAbstraction.Interface { ) {} async execute(groupId: string): Promise> { - const tenant = this.tenantContext.getTenant(); + try { + const tenant = this.tenantContext.getTenant(); - // Fetch all groups (plugin + database) with access control filtering - const groups = await this.fetchAllGroups(tenant.id); + // Fetch all groups (plugin + database) with access control filtering + const groups = await this.fetchAllGroups(tenant.id); - const group = groups.find(g => g.id === groupId); + const group = groups.find(g => g.id === groupId); - if (!group) { - return Result.fail(new GroupNotFoundError(groupId)); - } + if (!group) { + return Result.fail(new GroupNotFoundError(groupId)); + } - return Result.ok(group); + return Result.ok(group); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } } private async fetchAllGroups(tenant: string): Promise { diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts index a34f4a4e472..a3a94ca8589 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts @@ -2,6 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; import type { NotAuthorizedError } from "~/utils/errors.js"; /** @@ -35,6 +36,7 @@ export interface IGetGroupRepository { export interface IGetGroupRepositoryErrors { notFound: GroupNotFoundError; + storage: GroupStorageError; } type RepositoryError = IGetGroupRepositoryErrors[keyof IGetGroupRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts new file mode 100644 index 00000000000..4c640205102 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts @@ -0,0 +1,99 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListGroupsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; +import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * ListGroupsRepository - Fetches all groups (plugin + database). + * + * Responsibilities: + * - Create cache keys based on tenant + identity + * - Provide data loader functions to GroupCache + * - Fetch from plugin groups + database groups + * - Apply access control filtering + * - Return all accessible groups + */ +class ListGroupsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private groupCache: GroupCache.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute(): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // Fetch all groups (plugin + database) with access control filtering + const groups = await this.fetchAllGroups(tenant.id); + + return Result.ok(groups); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + private async fetchAllGroups(tenant: string): Promise { + // 1. Fetch plugin groups (with caching and access control) + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + + // 2. Fetch database groups (with caching) + const dbCacheKey = createCacheKey({ tenant }); + const databaseGroups = await this.groupCache.getOrSet(dbCacheKey, async () => { + return await this.storageOperations.groups.list({ + where: { tenant } + }); + }); + + // 3. Apply access control to database groups (with caching) + const filteredCacheKey = createCacheKey({ + dbCacheKey: dbCacheKey.get(), + identity: this.cmsContext.security.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined + }); + + const filteredDatabaseGroups = await this.groupCache.getOrSet( + filteredCacheKey, + async () => { + return filterAsync(databaseGroups, async (group?: CmsGroup) => { + if (!group) { + return false; + } + return this.accessControl.canAccessGroup({ group }); + }); + } + ); + + // 4. Merge plugin + database groups + return [...pluginGroups, ...filteredDatabaseGroups]; + } +} + +export const ListGroupsRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: ListGroupsRepositoryImpl, + dependencies: [ + GroupCache, + PluginGroupsProvider, + StorageOperations, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts new file mode 100644 index 00000000000..c8dd77942e8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts @@ -0,0 +1,46 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { ListGroupsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListGroupsRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * ListGroupsUseCase - Retrieves all content model groups. + * + * Responsibilities: + * - Apply initial access control check + * - Delegate to repository (which uses GroupCache for plugin + DB groups) + * - Return all accessible groups + */ +class ListGroupsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: ListGroupsRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(): Promise> { + // Initial access control check (no specific group yet) + const canAccess = await this.accessControl.canAccessGroup(); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Repository uses GroupCache to fetch all groups + // GroupCache handles merging plugin + database groups and access control + const result = await this.repository.execute(); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const ListGroupsUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: ListGroupsUseCaseImpl, + dependencies: [ListGroupsRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts new file mode 100644 index 00000000000..31979624b1f --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts @@ -0,0 +1,48 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * ListGroups Use Case + */ +export interface IListGroupsUseCase { + execute(): Promise>; +} + +export interface IListGroupsUseCaseErrors { + notAuthorized: NotAuthorizedError; + repository: RepositoryError; +} + +type UseCaseError = IListGroupsUseCaseErrors[keyof IListGroupsUseCaseErrors]; + +export const ListGroupsUseCase = createAbstraction("ListGroupsUseCase"); + +export namespace ListGroupsUseCase { + export type Interface = IListGroupsUseCase; + export type Error = UseCaseError; +} + +/** + * ListGroupsRepository - Fetches all groups from cache. + */ +export interface IListGroupsRepository { + execute(): Promise>; +} + +export interface IListGroupsRepositoryErrors { + storage: GroupStorageError; +} + +type RepositoryError = IListGroupsRepositoryErrors[keyof IListGroupsRepositoryErrors]; + +export const ListGroupsRepository = createAbstraction( + "ListGroupsRepository" +); + +export namespace ListGroupsRepository { + export type Interface = IListGroupsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/feature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/feature.ts new file mode 100644 index 00000000000..40c3a478f86 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListGroupsUseCase } from "./ListGroupsUseCase.js"; +import { ListGroupsRepository } from "./ListGroupsRepository.js"; + +/** + * ListGroups Feature + * + * Provides functionality for retrieving all content model groups. + * Uses GroupCache (registered elsewhere) for fetching groups. + */ +export const ListGroupsFeature = createFeature({ + name: "ListGroups", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(ListGroupsUseCase); + + // Register repository in singleton scope (shared instance) + container.register(ListGroupsRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/index.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; From ef2e1a4b6c6e499132602d609321d22750ce9c7e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 12:56:38 +0100 Subject: [PATCH 13/71] wip: migrate content group model domain --- .../contentAPI/contentModelGroup.crud.test.ts | 21 ++- .../src/crud/contentModelGroup.crud.ts | 64 +-------- .../ContentModelGroupFeature.ts | 2 + .../CreateGroup/CreateGroupRepository.ts | 114 +++++++++++++++ .../CreateGroup/CreateGroupUseCase.ts | 131 ++++++++++++++++++ .../CreateGroup/abstractions.ts | 53 +++++++ .../contentModelGroup/CreateGroup/events.ts | 82 +++++++++++ .../contentModelGroup/CreateGroup/feature.ts | 20 +++ .../contentModelGroup/CreateGroup/index.ts | 2 + 9 files changed, 421 insertions(+), 68 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/index.ts diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts index 0f4a23a6b99..7ce7319426d 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts @@ -1,8 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, test } from "vitest"; import { identity } from "../testHelpers/helpers"; import { toSlug } from "~/utils/toSlug"; import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; -import * as readline from "node:readline"; enum TestHelperEnum { MODELS_AMOUNT = 3, @@ -64,7 +63,7 @@ describe("Group crud test", () => { deleteContentModelGroupMutation } = useGraphQLHandler({ path: "manage" }); - it("content model group create, read, update, delete and list all at once", async () => { + test("content model group create, read, update, delete and list all at once", async () => { const updatedContentModelGroups = []; const prefixes = Array.from(Array(TestHelperEnum.MODELS_AMOUNT).keys()).map(prefix => { return createContentModelGroupPrefix(prefix); @@ -179,7 +178,7 @@ describe("Group crud test", () => { }); }); - it("error when getting non-existing content model group", async () => { + test("error when getting non-existing content model group", async () => { const [response] = await getContentModelGroupQuery({ id: "nonExistingId" }); @@ -197,7 +196,7 @@ describe("Group crud test", () => { }); }); - it("error when trying to update non-existing content model group", async () => { + test("error when trying to update non-existing content model group", async () => { const [response] = await updateContentModelGroupMutation({ id: "nonExistingIdUpdate", data: { @@ -219,7 +218,7 @@ describe("Group crud test", () => { }); }); - it("error when trying to delete non-existing content model group", async () => { + test("error when trying to delete non-existing content model group", async () => { const [response] = await deleteContentModelGroupMutation({ id: "nonExistingIdDelete" }); @@ -237,7 +236,7 @@ describe("Group crud test", () => { }); }); - it("error when trying to create a content model group with incomplete data", async () => { + test("error when trying to create a content model group with incomplete data", async () => { const [nameResponse] = await createContentModelGroupMutation({ data: { name: "", @@ -290,7 +289,7 @@ describe("Group crud test", () => { }); }); - it("error when trying to create a new content model group with no name or slug", async () => { + test("error when trying to create a new content model group with no name or slug", async () => { const [response] = await createContentModelGroupMutation({ data: { name: "", @@ -317,7 +316,7 @@ describe("Group crud test", () => { }); }); - it("error when trying to create a new content model group with same slug as existing one in the database", async () => { + test("error when trying to create a new content model group with same slug as existing one in the database", async () => { await createContentModelGroupMutation({ data: { name: "content model group", @@ -349,7 +348,7 @@ describe("Group crud test", () => { }); }); - it("list specific content model groups", async () => { + test("list specific content model groups", async () => { // Create few content model groups const prefixes = Array.from(Array(TestHelperEnum.MODELS_AMOUNT).keys()).map(prefix => { return createContentModelGroupPrefix(prefix); @@ -392,7 +391,7 @@ describe("Group crud test", () => { expect(response.data.listContentModelGroups.data[0].id).toEqual(groups[0]); }); - it("should allow to create a group with custom ID", async () => { + test("should allow to create a group with custom ID", async () => { const data = { id: "a-custom-group-id", name: "My Group With ID", diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index 3bc22970729..f0a4cdf5a8c 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -33,6 +33,7 @@ import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; import { ListGroupsUseCase } from "~/features/contentModelGroup/ListGroups/index.js"; +import { CreateGroupUseCase } from "~/features/contentModelGroup/CreateGroup/index.js"; export interface CreateModelGroupsCrudParams { getTenant: () => Tenant; @@ -198,66 +199,15 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG }; const createGroup: CmsGroupContext["createGroup"] = async input => { - await accessControl.ensureCanAccessGroup({ rwd: "w" }); - - const result = await createGroupCreateValidation().safeParseAsync(input); + const useCase = context.container.resolve(CreateGroupUseCase); + const result = await useCase.execute(input); - if (!result.success) { - throw createZodError(result.error); + if (result.isFail()) { + const error = result.error; + throw new WebinyError(error.message, error.code, error.data); } - const data = result.data; - - const identity = getIdentity(); - const id = data.id || mdbid(); - const group: CmsGroup = { - ...data, - id, - tenant: getTenant().id, - createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - webinyVersion: context.WEBINY_VERSION - }; - - await accessControl.ensureCanAccessGroup({ group, rwd: "w" }); - - try { - await onGroupBeforeCreate.publish({ - group - }); - - const result = await storageOperations.groups.create({ - group - }); - - clearGroupsCache(); - - await onGroupAfterCreate.publish({ - group: result - }); - - return group; - } catch (ex) { - await onGroupCreateError.publish({ - input, - group, - error: ex - }); - throw new WebinyError( - ex.message || "Could not save data model group.", - ex.code || "ERROR_ON_CREATE", - { - ...(ex.data || {}), - group, - input - } - ); - } + return result.value; }; const updateGroup: CmsGroupContext["updateGroup"] = async (id, input) => { await accessControl.ensureCanAccessGroup({ rwd: "w" }); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts index c2b9cde5c05..ef865ec2c77 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -1,6 +1,7 @@ import { createFeature } from "@webiny/feature/api"; import { GetGroupFeature } from "~/features/contentModelGroup/GetGroup/feature.js"; import { ListGroupsFeature } from "~/features/contentModelGroup/ListGroups/feature.js"; +import { CreateGroupFeature } from "~/features/contentModelGroup/CreateGroup/feature.js"; import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; @@ -16,5 +17,6 @@ export const ContentModelGroupFeature = createFeature({ ListGroupsFeature.register(container); // Command features + CreateGroupFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts new file mode 100644 index 00000000000..9c152fff8fe --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts @@ -0,0 +1,114 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { CreateGroupRepository as RepositoryAbstraction } from "./abstractions.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; +import { GroupAlreadyExistsError } from "~/domain/contentModelGroup/errors.js"; +import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { toSlug } from "~/utils/toSlug.js"; +import { generateAlphaNumericId } from "@webiny/utils"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * CreateGroupRepository - Validates and persists a new group. + * + * Responsibilities: + * - Validate ID uniqueness (if provided) + * - Validate slug uniqueness (or generate unique slug) + * - Check for plugin group conflicts + * - Persist to storage + * - Clear GroupCache after successful create + */ +class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private groupCache: GroupCache.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private tenantContext: TenantContext.Interface + ) {} + + async execute(group: CmsGroup): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // 1. Validate ID uniqueness (if provided) + if (group.id) { + const existingById = await this.storageOperations.groups.list({ + where: { + tenant: tenant.id, + id: group.id + } + }); + if (existingById.length > 0) { + return Result.fail(new GroupAlreadyExistsError(group.id)); + } + } + + // 2. Generate or validate slug + await this.ensureUniqueSlug(group, tenant.id); + + // 3. Check for plugin group conflicts + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const pluginGroupConflict = pluginGroups.find(pg => pg.slug === group.slug); + if (pluginGroupConflict) { + return Result.fail( + new GroupAlreadyExistsError( + `Cannot create "${group.slug}" because it's registered via a plugin` + ) + ); + } + + // 4. Persist to storage + await this.storageOperations.groups.create({ group }); + + // 5. Clear cache + this.groupCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } + + private async ensureUniqueSlug(group: CmsGroup, tenant: string): Promise { + // If slug is provided and not empty, validate it + if (group.slug && group.slug.trim()) { + const existingBySlug = await this.storageOperations.groups.list({ + where: { + tenant, + slug: group.slug + } + }); + if (existingBySlug.length > 0) { + throw new GroupAlreadyExistsError(`Slug "${group.slug}" already exists`); + } + return; + } + + // Generate slug from name + const baseSlug = toSlug(group.name); + const existingBySlug = await this.storageOperations.groups.list({ + where: { + tenant, + slug: baseSlug + } + }); + + if (existingBySlug.length === 0) { + // No conflict, use base slug + group.slug = baseSlug; + } else { + // Conflict, append random suffix + group.slug = `${baseSlug}-${generateAlphaNumericId(8)}`; + } + } +} + +export const CreateGroupRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: CreateGroupRepositoryImpl, + dependencies: [GroupCache, PluginGroupsProvider, StorageOperations, TenantContext] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts new file mode 100644 index 00000000000..899842d8e8b --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts @@ -0,0 +1,131 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { CreateGroupUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CreateGroupRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { GroupBeforeCreateEvent } from "./events.js"; +import { GroupAfterCreateEvent } from "./events.js"; +import { GroupCreateErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { createZodError } from "@webiny/utils"; +import { mdbid } from "@webiny/utils"; +import { createGroupCreateValidation } from "~/crud/contentModelGroup/validation.js"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupCreateInput } from "~/types/index.js"; + +/** + * CreateGroupUseCase - Orchestrates group creation. + * + * Responsibilities: + * - Validate input (Zod) + * - Create domain group object + * - Access control checks + * - Publish before event + * - Delegate to repository + * - Publish after event or error event + */ +class CreateGroupUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: CreateGroupRepository.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + input: CmsGroupCreateInput + ): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessGroup({ rwd: "w" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Validate input + const validationResult = await createGroupCreateValidation().safeParseAsync(input); + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new GroupValidationError(zodError.message)); + } + const data = validationResult.data; + + // Create domain group object + const identity = this.identityContext.getIdentity(); + const tenant = this.tenantContext.getTenant(); + + const id = data.id || mdbid(); + const group: CmsGroup = { + ...data, + id, + tenant: tenant.id, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + createdBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }, + webinyVersion: this.cmsContext.WEBINY_VERSION + }; + + // Access control check on created group + const canAccessGroup = await this.accessControl.canAccessGroup({ group, rwd: "w" }); + if (!canAccessGroup) { + return Result.fail(new NotAuthorizedError()); + } + + try { + // Publish before event + await this.eventPublisher.publish(new GroupBeforeCreateEvent({ group })); + + // Persist via repository + const result = await this.repository.execute(group); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new GroupCreateErrorEvent({ + input, + group, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish(new GroupAfterCreateEvent({ group })); + + return Result.ok(group); + } catch (error) { + // Publish error event for unexpected errors + await this.eventPublisher.publish( + new GroupCreateErrorEvent({ + input, + group, + error: error as Error + }) + ); + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const CreateGroupUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: CreateGroupUseCaseImpl, + dependencies: [ + EventPublisher, + CreateGroupRepository, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts new file mode 100644 index 00000000000..7c9ff6ee7c9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts @@ -0,0 +1,53 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupCreateInput } from "~/types/index.js"; +import type { GroupAlreadyExistsError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * CreateGroup Use Case + */ +export interface ICreateGroupUseCase { + execute(input: CmsGroupCreateInput): Promise>; +} + +export interface ICreateGroupUseCaseErrors { + notAuthorized: NotAuthorizedError; + validation: GroupValidationError; + repository: RepositoryError; +} + +type UseCaseError = ICreateGroupUseCaseErrors[keyof ICreateGroupUseCaseErrors]; + +export const CreateGroupUseCase = createAbstraction("CreateGroupUseCase"); + +export namespace CreateGroupUseCase { + export type Interface = ICreateGroupUseCase; + export type Error = UseCaseError; +} + +/** + * CreateGroupRepository - Persists a new group to storage. + */ +export interface ICreateGroupRepository { + execute(group: CmsGroup): Promise>; +} + +export interface ICreateGroupRepositoryErrors { + alreadyExists: GroupAlreadyExistsError; + storage: GroupStorageError; +} + +type RepositoryError = ICreateGroupRepositoryErrors[keyof ICreateGroupRepositoryErrors]; + +export const CreateGroupRepository = createAbstraction( + "CreateGroupRepository" +); + +export namespace CreateGroupRepository { + export type Interface = ICreateGroupRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts new file mode 100644 index 00000000000..8f5bcb406df --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts @@ -0,0 +1,82 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupCreateInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface GroupBeforeCreatePayload { + group: CmsGroup; +} + +export interface GroupAfterCreatePayload { + group: CmsGroup; +} + +export interface GroupCreateErrorPayload { + input: CmsGroupCreateInput; + group: CmsGroup; + error: Error; +} + +/** + * GroupBeforeCreateEvent - Published before creating a group + */ +export class GroupBeforeCreateEvent extends DomainEvent { + eventType = "Cms/Group/BeforeCreate" as const; + + getHandlerAbstraction() { + return GroupBeforeCreateHandler; + } +} + +export const GroupBeforeCreateHandler = createAbstraction>( + "GroupBeforeCreateHandler" +); + +export namespace GroupBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = GroupBeforeCreateEvent; +} + +/** + * GroupAfterCreateEvent - Published after creating a group + */ +export class GroupAfterCreateEvent extends DomainEvent { + eventType = "Cms/Group/AfterCreate" as const; + + getHandlerAbstraction() { + return GroupAfterCreateHandler; + } +} + +export const GroupAfterCreateHandler = createAbstraction>( + "GroupAfterCreateHandler" +); + +export namespace GroupAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = GroupAfterCreateEvent; +} + +/** + * GroupCreateErrorEvent - Published when create fails + */ +export class GroupCreateErrorEvent extends DomainEvent { + eventType = "Cms/Group/CreateError" as const; + + getHandlerAbstraction() { + return GroupCreateErrorHandler; + } +} + +export const GroupCreateErrorHandler = createAbstraction>( + "GroupCreateErrorHandler" +); + +export namespace GroupCreateErrorHandler { + export type Interface = IEventHandler; + export type Event = GroupCreateErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/feature.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/feature.ts new file mode 100644 index 00000000000..918f99d0bcf --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateGroupUseCase } from "./CreateGroupUseCase.js"; +import { CreateGroupRepository } from "./CreateGroupRepository.js"; + +/** + * CreateGroup Feature + * + * Provides functionality for creating new content model groups. + * Includes validation, slug generation, and plugin conflict checks. + */ +export const CreateGroupFeature = createFeature({ + name: "CreateGroup", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(CreateGroupUseCase); + + // Register repository in singleton scope (shared instance) + container.register(CreateGroupRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/index.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; From 3c19c5c311616b7f09e366d43e67527ae616aaf2 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 14:04:22 +0100 Subject: [PATCH 14/71] wip: migrate content group model domain --- .../src/crud/contentModelGroup.crud.ts | 166 +----------------- .../crud/contentModelGroup/beforeCreate.ts | 77 -------- .../crud/contentModelGroup/beforeUpdate.ts | 22 --- .../ContentModelGroupFeature.ts | 2 + .../UpdateGroup/UpdateGroupRepository.ts | 53 ++++++ .../UpdateGroup/UpdateGroupUseCase.ts | 146 +++++++++++++++ .../UpdateGroup/abstractions.ts | 55 ++++++ .../contentModelGroup/UpdateGroup/events.ts | 85 +++++++++ .../contentModelGroup/UpdateGroup/feature.ts | 20 +++ .../contentModelGroup/UpdateGroup/index.ts | 2 + 10 files changed, 372 insertions(+), 256 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts delete mode 100644 packages/api-headless-cms/src/crud/contentModelGroup/beforeUpdate.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/index.ts diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index f0a4cdf5a8c..f229221930e 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -1,5 +1,4 @@ import WebinyError from "@webiny/error"; -import { NotFoundError } from "@webiny/handler-graphql"; import type { CmsContext, CmsGroup, @@ -15,44 +14,23 @@ import type { OnGroupDeleteErrorTopicParams, OnGroupUpdateErrorTopicParams } from "~/types/index.js"; -import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin.js"; import { createTopic } from "@webiny/pubsub"; -import { assignBeforeGroupUpdate } from "./contentModelGroup/beforeUpdate.js"; -import { assignBeforeGroupCreate } from "./contentModelGroup/beforeCreate.js"; import { assignBeforeGroupDelete } from "./contentModelGroup/beforeDelete.js"; -import { - createGroupCreateValidation, - createGroupUpdateValidation -} from "~/crud/contentModelGroup/validation.js"; -import { createZodError, mdbid } from "@webiny/utils"; -import { filterAsync } from "~/utils/filterAsync.js"; -import { createCacheKey, createMemoryCache } from "~/utils/index.js"; -import { listGroupsFromDatabase } from "~/crud/contentModelGroup/listGroupsFromDatabase.js"; +import { createMemoryCache } from "~/utils/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; -import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; import { ListGroupsUseCase } from "~/features/contentModelGroup/ListGroups/index.js"; import { CreateGroupUseCase } from "~/features/contentModelGroup/CreateGroup/index.js"; +import { UpdateGroupUseCase } from "~/features/contentModelGroup/UpdateGroup/index.js"; export interface CreateModelGroupsCrudParams { - getTenant: () => Tenant; storageOperations: HeadlessCmsStorageOperations; accessControl: AccessControl; context: CmsContext; - getIdentity: () => SecurityIdentity; } export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsGroupContext => { - const { getTenant, getIdentity, storageOperations, accessControl, context } = params; - - const filterGroup = async (group?: CmsGroup) => { - if (!group) { - return false; - } - - return accessControl.canAccessGroup({ group }); - }; + const { storageOperations, accessControl, context } = params; const listDatabaseGroupsCache = createMemoryCache>(); const listFilteredDatabaseGroupsCache = createMemoryCache>(); @@ -63,74 +41,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG listFilteredDatabaseGroupsCache.clear(); }; - const fetchPluginGroups = (tenant: string): Promise => { - const pluginGroups = context.plugins.byType(CmsGroupPlugin.type); - - const cacheKey = createCacheKey({ - tenant, - identity: context.security.isAuthorizationEnabled() ? getIdentity()?.id : undefined, - groups: pluginGroups - .map(({ contentModelGroup: group }) => { - return `${group.id}#${group.slug}#${group.savedOn || "unknown"}`; - }) - .join("/") - }); - - return listPluginGroupsCache.getOrSet(cacheKey, async (): Promise => { - const groups = pluginGroups - /** - * We need to filter out groups that are not for this tenant or locale. - * If it does not have tenant or locale define, it is for every locale and tenant - */ - .filter(plugin => { - const { tenant: t } = plugin.contentModelGroup; - if (t && t !== tenant) { - return false; - } - return true; - }) - .map(plugin => { - return { - ...plugin.contentModelGroup, - tenant, - webinyVersion: context.WEBINY_VERSION - }; - }); - return filterAsync(groups, filterGroup); - }); - }; - - const fetchGroups = async (tenant: string) => { - const pluginGroups = await fetchPluginGroups(tenant); - /** - * Maybe we can cache based on permissions, not the identity id? - * - * TODO: @adrian please check if possible. - */ - const cacheKey = createCacheKey({ - tenant - }); - const databaseGroups = await listDatabaseGroupsCache.getOrSet(cacheKey, async () => { - return await listGroupsFromDatabase({ - storageOperations, - tenant - }); - }); - const filteredCacheKey = createCacheKey({ - dbCacheKey: cacheKey.get(), - identity: context.security.isAuthorizationEnabled() ? getIdentity()?.id : undefined - }); - - const groups = await listFilteredDatabaseGroupsCache.getOrSet( - filteredCacheKey, - async () => { - return filterAsync(databaseGroups, filterGroup); - } - ); - - return groups.concat(pluginGroups); - }; - /** * Create */ @@ -156,15 +66,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG /** * We need to assign some default behaviors. */ - assignBeforeGroupCreate({ - onGroupBeforeCreate, - plugins: context.plugins, - storageOperations - }); - assignBeforeGroupUpdate({ - onGroupBeforeUpdate, - plugins: context.plugins - }); assignBeforeGroupDelete({ onGroupBeforeDelete, plugins: context.plugins, @@ -210,64 +111,15 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG return result.value; }; const updateGroup: CmsGroupContext["updateGroup"] = async (id, input) => { - await accessControl.ensureCanAccessGroup({ rwd: "w" }); - - const original = await getGroup(id); - - await accessControl.ensureCanAccessGroup({ group: original }); - - const result = await createGroupUpdateValidation().safeParseAsync(input); - - if (!result.success) { - throw createZodError(result.error); - } - const data = result.data; + const useCase = context.container.resolve(UpdateGroupUseCase); + const result = await useCase.execute(id, input); - /** - * No need to continue if no values were changed - */ - if (Object.keys(data).length === 0) { - return original; + if (result.isFail()) { + const error = result.error; + throw new WebinyError(error.message, error.code, error.data); } - const group: CmsGroup = { - ...original, - ...data, - tenant: getTenant().id, - savedOn: new Date().toISOString() - }; - - try { - await onGroupBeforeUpdate.publish({ - original, - group - }); - - const updatedGroup = await storageOperations.groups.update({ - group - }); - clearGroupsCache(); - - await onGroupAfterUpdate.publish({ - original, - group: updatedGroup - }); - - return updatedGroup; - } catch (ex) { - await onGroupUpdateError.publish({ - input, - original, - group, - error: ex - }); - throw new WebinyError(ex.message, ex.code || "UPDATE_ERROR", { - error: ex, - original, - group, - input - }); - } + return result.value; }; const deleteGroup: CmsGroupContext["deleteGroup"] = async id => { await accessControl.ensureCanAccessGroup({ rwd: "d" }); diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts b/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts deleted file mode 100644 index 3b2018dd946..00000000000 --- a/packages/api-headless-cms/src/crud/contentModelGroup/beforeCreate.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { - HeadlessCmsStorageOperations, - OnGroupBeforeCreateTopicParams -} from "~/types/index.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import type { PluginsContainer } from "@webiny/plugins"; -import WebinyError from "@webiny/error"; -import { toSlug } from "~/utils/toSlug.js"; -import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin.js"; -import { generateAlphaNumericId } from "@webiny/utils"; - -interface AssignBeforeGroupCreateParams { - onGroupBeforeCreate: Topic; - plugins: PluginsContainer; - storageOperations: HeadlessCmsStorageOperations; -} -export const assignBeforeGroupCreate = (params: AssignBeforeGroupCreateParams) => { - const { onGroupBeforeCreate, plugins, storageOperations } = params; - - onGroupBeforeCreate.subscribe(async params => { - const { group } = params; - - if (group.id) { - const groups = await storageOperations.groups.list({ - where: { - tenant: group.tenant, - id: group.id - } - }); - if (groups.length > 0) { - throw new WebinyError( - `Cms Group with the id "${group.id}" already exists.`, - "ID_ALREADY_EXISTS" - ); - } - } - - if (group.slug && group.slug.trim()) { - const groups = await storageOperations.groups.list({ - where: { - tenant: group.tenant, - slug: group.slug - } - }); - if (groups.length > 0) { - throw new WebinyError( - `Cms Group with the slug "${group.slug}" already exists.`, - "SLUG_ALREADY_EXISTS" - ); - } - } else { - const slug = toSlug(group.name); - const groups = await storageOperations.groups.list({ - where: { - tenant: group.tenant, - slug - } - }); - - if (groups.length === 0) { - group.slug = slug; - } else { - group.slug = `${slug}-${generateAlphaNumericId(8)}`; - } - } - - const groupPlugin = plugins - .byType(CmsGroupPlugin.type) - .find(item => item.contentModelGroup.slug === group.slug); - - if (groupPlugin) { - throw new Error( - `Cannot create "${group.slug}" content model group because one is already registered via a plugin.` - ); - } - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/beforeUpdate.ts b/packages/api-headless-cms/src/crud/contentModelGroup/beforeUpdate.ts deleted file mode 100644 index 81bfb07db11..00000000000 --- a/packages/api-headless-cms/src/crud/contentModelGroup/beforeUpdate.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { OnGroupBeforeUpdateTopicParams } from "~/types/index.js"; -import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin.js"; -import type { PluginsContainer } from "@webiny/plugins"; - -interface AssignBeforeGroupUpdateParams { - onGroupBeforeUpdate: Topic; - plugins: PluginsContainer; -} -export const assignBeforeGroupUpdate = (params: AssignBeforeGroupUpdateParams) => { - const { onGroupBeforeUpdate, plugins } = params; - - onGroupBeforeUpdate.subscribe(({ group }) => { - const groupPlugin = plugins - .byType(CmsGroupPlugin.type) - .find(item => item.contentModelGroup.slug === group.slug); - if (!groupPlugin) { - return; - } - throw new Error(`Cms Groups defined via plugins cannot be updated.`); - }); -}; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts index ef865ec2c77..4c6953693e8 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -2,6 +2,7 @@ import { createFeature } from "@webiny/feature/api"; import { GetGroupFeature } from "~/features/contentModelGroup/GetGroup/feature.js"; import { ListGroupsFeature } from "~/features/contentModelGroup/ListGroups/feature.js"; import { CreateGroupFeature } from "~/features/contentModelGroup/CreateGroup/feature.js"; +import { UpdateGroupFeature } from "~/features/contentModelGroup/UpdateGroup/feature.js"; import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; @@ -18,5 +19,6 @@ export const ContentModelGroupFeature = createFeature({ // Command features CreateGroupFeature.register(container); + UpdateGroupFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts new file mode 100644 index 00000000000..829c97bebd6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { UpdateGroupRepository as RepositoryAbstraction } from "./abstractions.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; +import { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; +import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * UpdateGroupRepository - Validates and persists group updates. + * + * Responsibilities: + * - Check if group is plugin-based (cannot update) + * - Persist updates to storage + * - Clear GroupCache after successful update + */ +class UpdateGroupRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private groupCache: GroupCache.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute(group: CmsGroup): Promise> { + try { + // Check if this is a plugin-based group (cannot be updated) + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const isPluginGroup = pluginGroups.some(pg => pg.slug === group.slug); + + if (isPluginGroup) { + return Result.fail(new GroupCannotUpdateCodeDefinedError(group.id)); + } + + // Persist updates + const updatedGroup = await this.storageOperations.groups.update({ group }); + + // Clear cache + this.groupCache.clear(); + + return Result.ok(updatedGroup); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } +} + +export const UpdateGroupRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: UpdateGroupRepositoryImpl, + dependencies: [GroupCache, PluginGroupsProvider, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts new file mode 100644 index 00000000000..210c596c627 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts @@ -0,0 +1,146 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { UpdateGroupUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { UpdateGroupRepository } from "./abstractions.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { GroupBeforeUpdateEvent } from "./events.js"; +import { GroupAfterUpdateEvent } from "./events.js"; +import { GroupUpdateErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; +import { createZodError } from "@webiny/utils"; +import { createGroupUpdateValidation } from "~/crud/contentModelGroup/validation.js"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupUpdateInput } from "~/types/index.js"; + +/** + * UpdateGroupUseCase - Orchestrates group updates. + * + * Responsibilities: + * - Fetch original group + * - Access control checks + * - Validate input (Zod) + * - Skip if no changes + * - Merge changes + * - Publish before event + * - Delegate to repository + * - Publish after event or error event + */ +class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: UpdateGroupRepository.Interface, + private getGroupUseCase: GetGroupUseCase.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface + ) {} + + async execute( + groupId: string, + input: CmsGroupUpdateInput + ): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessGroup({ rwd: "w" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Fetch original group + const getResult = await this.getGroupUseCase.execute(groupId); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + const original = getResult.value; + + // Access control check on original group + const canAccessGroup = await this.accessControl.canAccessGroup({ group: original }); + if (!canAccessGroup) { + return Result.fail(new NotAuthorizedError()); + } + + // Validate input + const validationResult = await createGroupUpdateValidation().safeParseAsync(input); + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new GroupValidationError(zodError.message)); + } + const data = validationResult.data; + + // Skip if no changes + if (Object.keys(data).length === 0) { + return Result.ok(original); + } + + // Merge changes + const tenant = this.tenantContext.getTenant(); + const group: CmsGroup = { + ...original, + ...data, + tenant: tenant.id, + savedOn: new Date().toISOString() + }; + + try { + // Publish before event + await this.eventPublisher.publish( + new GroupBeforeUpdateEvent({ + original, + group + }) + ); + + // Persist via repository + const result = await this.repository.execute(group); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new GroupUpdateErrorEvent({ + input, + original, + group, + error: result.error + }) + ); + return Result.fail(result.error); + } + + const updatedGroup = result.value; + + // Publish after event + await this.eventPublisher.publish( + new GroupAfterUpdateEvent({ + original, + group: updatedGroup + }) + ); + + return Result.ok(updatedGroup); + } catch (error) { + // Publish error event for unexpected errors + await this.eventPublisher.publish( + new GroupUpdateErrorEvent({ + input, + original, + group, + error: error as Error + }) + ); + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const UpdateGroupUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: UpdateGroupUseCaseImpl, + dependencies: [ + EventPublisher, + UpdateGroupRepository, + GetGroupUseCase, + AccessControl, + TenantContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts new file mode 100644 index 00000000000..14682e0200c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupUpdateInput } from "~/types/index.js"; +import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * UpdateGroup Use Case + */ +export interface IUpdateGroupUseCase { + execute(groupId: string, input: CmsGroupUpdateInput): Promise>; +} + +export interface IUpdateGroupUseCaseErrors { + notFound: GroupNotFoundError; + notAuthorized: NotAuthorizedError; + validation: GroupValidationError; + repository: RepositoryError; +} + +type UseCaseError = IUpdateGroupUseCaseErrors[keyof IUpdateGroupUseCaseErrors]; + +export const UpdateGroupUseCase = createAbstraction("UpdateGroupUseCase"); + +export namespace UpdateGroupUseCase { + export type Interface = IUpdateGroupUseCase; + export type Error = UseCaseError; +} + +/** + * UpdateGroupRepository - Persists group updates to storage. + */ +export interface IUpdateGroupRepository { + execute(group: CmsGroup): Promise>; +} + +export interface IUpdateGroupRepositoryErrors { + cannotUpdate: GroupCannotUpdateCodeDefinedError; + storage: GroupStorageError; +} + +type RepositoryError = IUpdateGroupRepositoryErrors[keyof IUpdateGroupRepositoryErrors]; + +export const UpdateGroupRepository = createAbstraction( + "UpdateGroupRepository" +); + +export namespace UpdateGroupRepository { + export type Interface = IUpdateGroupRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts new file mode 100644 index 00000000000..a1dbec1149f --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts @@ -0,0 +1,85 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsGroup } from "~/types/index.js"; +import type { CmsGroupUpdateInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface GroupBeforeUpdatePayload { + original: CmsGroup; + group: CmsGroup; +} + +export interface GroupAfterUpdatePayload { + original: CmsGroup; + group: CmsGroup; +} + +export interface GroupUpdateErrorPayload { + input: CmsGroupUpdateInput; + original: CmsGroup; + group: CmsGroup; + error: Error; +} + +/** + * GroupBeforeUpdateEvent - Published before updating a group + */ +export class GroupBeforeUpdateEvent extends DomainEvent { + eventType = "Cms/Group/BeforeUpdate" as const; + + getHandlerAbstraction() { + return GroupBeforeUpdateHandler; + } +} + +export const GroupBeforeUpdateHandler = createAbstraction>( + "GroupBeforeUpdateHandler" +); + +export namespace GroupBeforeUpdateHandler { + export type Interface = IEventHandler; + export type Event = GroupBeforeUpdateEvent; +} + +/** + * GroupAfterUpdateEvent - Published after updating a group + */ +export class GroupAfterUpdateEvent extends DomainEvent { + eventType = "Cms/Group/AfterUpdate" as const; + + getHandlerAbstraction() { + return GroupAfterUpdateHandler; + } +} + +export const GroupAfterUpdateHandler = createAbstraction>( + "GroupAfterUpdateHandler" +); + +export namespace GroupAfterUpdateHandler { + export type Interface = IEventHandler; + export type Event = GroupAfterUpdateEvent; +} + +/** + * GroupUpdateErrorEvent - Published when update fails + */ +export class GroupUpdateErrorEvent extends DomainEvent { + eventType = "Cms/Group/UpdateError" as const; + + getHandlerAbstraction() { + return GroupUpdateErrorHandler; + } +} + +export const GroupUpdateErrorHandler = createAbstraction>( + "GroupUpdateErrorHandler" +); + +export namespace GroupUpdateErrorHandler { + export type Interface = IEventHandler; + export type Event = GroupUpdateErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/feature.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/feature.ts new file mode 100644 index 00000000000..9915d9883a6 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateGroupUseCase } from "./UpdateGroupUseCase.js"; +import { UpdateGroupRepository } from "./UpdateGroupRepository.js"; + +/** + * UpdateGroup Feature + * + * Provides functionality for updating existing content model groups. + * Prevents updates to plugin-based groups. + */ +export const UpdateGroupFeature = createFeature({ + name: "UpdateGroup", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(UpdateGroupUseCase); + + // Register repository in singleton scope (shared instance) + container.register(UpdateGroupRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/index.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; From aeb74a1fa11ccd751fee3bf3ce5cdb4e34412ea6 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 14:27:42 +0100 Subject: [PATCH 15/71] wip: migrate content group model domain --- packages/api-headless-cms/src/context.ts | 6 +- .../src/crud/contentModelGroup.crud.ts | 48 ++------- .../crud/contentModelGroup/beforeDelete.ts | 47 --------- .../listGroupsFromDatabase.ts | 16 --- .../src/domain/contentModelGroup/errors.ts | 10 ++ .../contentModelGroup/validation.ts | 0 .../src/export/crud/imports/validateGroups.ts | 2 +- .../ContentModelGroupFeature.ts | 2 + .../CreateGroup/CreateGroupUseCase.ts | 2 +- .../DeleteGroup/DeleteGroupRepository.ts | 67 +++++++++++++ .../DeleteGroup/DeleteGroupUseCase.ts | 97 +++++++++++++++++++ .../DeleteGroup/abstractions.ts | 54 +++++++++++ .../contentModelGroup/DeleteGroup/events.ts | 80 +++++++++++++++ .../contentModelGroup/DeleteGroup/feature.ts | 20 ++++ .../contentModelGroup/DeleteGroup/index.ts | 2 + .../UpdateGroup/UpdateGroupUseCase.ts | 2 +- 16 files changed, 344 insertions(+), 111 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts delete mode 100644 packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts rename packages/api-headless-cms/src/{crud => domain}/contentModelGroup/validation.ts (100%) create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/index.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index 90c1edb1bbc..196272ad803 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -130,11 +130,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { accessControl, getExecutableSchema, ...createModelGroupsCrud({ - context, - getTenant, - getIdentity, - storageOperations, - accessControl + context }), ...createModelsCrud({ context, diff --git a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts index f229221930e..583b9387a9a 100644 --- a/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModelGroup.crud.ts @@ -3,7 +3,6 @@ import type { CmsContext, CmsGroup, CmsGroupContext, - HeadlessCmsStorageOperations, OnGroupAfterCreateTopicParams, OnGroupAfterDeleteTopicParams, OnGroupAfterUpdateTopicParams, @@ -15,22 +14,19 @@ import type { OnGroupUpdateErrorTopicParams } from "~/types/index.js"; import { createTopic } from "@webiny/pubsub"; -import { assignBeforeGroupDelete } from "./contentModelGroup/beforeDelete.js"; import { createMemoryCache } from "~/utils/index.js"; -import type { AccessControl } from "./AccessControl/AccessControl.js"; import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; import { ListGroupsUseCase } from "~/features/contentModelGroup/ListGroups/index.js"; import { CreateGroupUseCase } from "~/features/contentModelGroup/CreateGroup/index.js"; import { UpdateGroupUseCase } from "~/features/contentModelGroup/UpdateGroup/index.js"; +import { DeleteGroupUseCase } from "~/features/contentModelGroup/DeleteGroup/index.js"; export interface CreateModelGroupsCrudParams { - storageOperations: HeadlessCmsStorageOperations; - accessControl: AccessControl; context: CmsContext; } export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsGroupContext => { - const { storageOperations, accessControl, context } = params; + const { context } = params; const listDatabaseGroupsCache = createMemoryCache>(); const listFilteredDatabaseGroupsCache = createMemoryCache>(); @@ -63,14 +59,6 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG const onGroupAfterDelete = createTopic("cms.onGroupAfterDelete"); const onGroupDeleteError = createTopic("cms.onGroupDeleteError"); - /** - * We need to assign some default behaviors. - */ - assignBeforeGroupDelete({ - onGroupBeforeDelete, - plugins: context.plugins, - storageOperations - }); /** * CRUD Methods */ @@ -86,7 +74,7 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG return result.value; }; - const listGroups: CmsGroupContext["listGroups"] = async params => { + const listGroups: CmsGroupContext["listGroups"] = async () => { const useCase = context.container.resolve(ListGroupsUseCase); const result = await useCase.execute(); @@ -122,32 +110,12 @@ export const createModelGroupsCrud = (params: CreateModelGroupsCrudParams): CmsG return result.value; }; const deleteGroup: CmsGroupContext["deleteGroup"] = async id => { - await accessControl.ensureCanAccessGroup({ rwd: "d" }); - - const group = await getGroup(id); - - await accessControl.ensureCanAccessGroup({ group }); - - try { - await onGroupBeforeDelete.publish({ - group - }); - - await storageOperations.groups.delete({ group }); - clearGroupsCache(); + const useCase = context.container.resolve(DeleteGroupUseCase); + const result = await useCase.execute(id); - await onGroupAfterDelete.publish({ - group - }); - } catch (ex) { - await onGroupDeleteError.publish({ - group, - error: ex - }); - throw new WebinyError(ex.message, ex.code || "DELETE_ERROR", { - ...(ex.data || {}), - id - }); + if (result.isFail()) { + const error = result.error; + throw new WebinyError(error.message, error.code, error.data); } return true; diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts b/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts deleted file mode 100644 index fa5997fa70a..00000000000 --- a/packages/api-headless-cms/src/crud/contentModelGroup/beforeDelete.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { - OnGroupBeforeDeleteTopicParams, - HeadlessCmsStorageOperations -} from "~/types/index.js"; -import type { PluginsContainer } from "@webiny/plugins"; -import { CmsGroupPlugin } from "~/plugins/CmsGroupPlugin.js"; -import WebinyError from "@webiny/error"; - -interface AssignBeforeGroupDeleteParams { - onGroupBeforeDelete: Topic; - plugins: PluginsContainer; - storageOperations: HeadlessCmsStorageOperations; -} -export const assignBeforeGroupDelete = (params: AssignBeforeGroupDeleteParams) => { - const { onGroupBeforeDelete, plugins, storageOperations } = params; - - onGroupBeforeDelete.subscribe(async params => { - const { group } = params; - - const groupPlugin = plugins - .byType(CmsGroupPlugin.type) - .find(item => item.contentModelGroup.slug === group.slug); - - if (groupPlugin) { - throw new Error(`Cms Groups defined via plugins cannot be deleted.`); - } - - const models = await storageOperations.models.list({ - where: { - tenant: group.tenant - } - }); - const items = models.filter(model => { - return model.group.id === group.id; - }); - if (items.length > 0) { - throw new WebinyError( - "Cannot delete this group because there are models that belong to it.", - "BEFORE_DELETE_ERROR", - { - group - } - ); - } - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts b/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts deleted file mode 100644 index cf3af5def63..00000000000 --- a/packages/api-headless-cms/src/crud/contentModelGroup/listGroupsFromDatabase.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { HeadlessCmsStorageOperations } from "~/types/index.js"; - -interface Params { - storageOperations: HeadlessCmsStorageOperations; - tenant: string; -} - -export const listGroupsFromDatabase = async (params: Params) => { - const { storageOperations, tenant } = params; - - return await storageOperations.groups.list({ - where: { - tenant - } - }); -}; diff --git a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts index de06f991283..cc86ab31763 100644 --- a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts @@ -59,3 +59,13 @@ export class GroupCannotDeleteCodeDefinedError extends BaseError { }); } } + +export class GroupHasModelsError extends BaseError { + override readonly code = "GROUP_HAS_MODELS" as const; + + constructor() { + super({ + message: `Cannot delete this group because there are models that belong to it.` + }); + } +} diff --git a/packages/api-headless-cms/src/crud/contentModelGroup/validation.ts b/packages/api-headless-cms/src/domain/contentModelGroup/validation.ts similarity index 100% rename from packages/api-headless-cms/src/crud/contentModelGroup/validation.ts rename to packages/api-headless-cms/src/domain/contentModelGroup/validation.ts diff --git a/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts b/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts index 4cb53ec843c..454d14103ea 100644 --- a/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts +++ b/packages/api-headless-cms/src/export/crud/imports/validateGroups.ts @@ -1,5 +1,5 @@ import type { CmsGroup } from "~/types/index.js"; -import { createGroupCreateValidation } from "~/crud/contentModelGroup/validation.js"; +import { createGroupCreateValidation } from "~/domain/contentModelGroup/validation.js"; import { createZodError } from "@webiny/utils"; import type { ValidatedCmsGroupResult } from "~/export/types.js"; import { CmsImportAction } from "~/export/types.js"; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts index 4c6953693e8..532350ecfeb 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -3,6 +3,7 @@ import { GetGroupFeature } from "~/features/contentModelGroup/GetGroup/feature.j import { ListGroupsFeature } from "~/features/contentModelGroup/ListGroups/feature.js"; import { CreateGroupFeature } from "~/features/contentModelGroup/CreateGroup/feature.js"; import { UpdateGroupFeature } from "~/features/contentModelGroup/UpdateGroup/feature.js"; +import { DeleteGroupFeature } from "~/features/contentModelGroup/DeleteGroup/feature.js"; import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; @@ -20,5 +21,6 @@ export const ContentModelGroupFeature = createFeature({ // Command features CreateGroupFeature.register(container); UpdateGroupFeature.register(container); + DeleteGroupFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts index 899842d8e8b..ca964e01e79 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts @@ -14,7 +14,7 @@ import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import { NotAuthorizedError } from "~/utils/errors.js"; import { createZodError } from "@webiny/utils"; import { mdbid } from "@webiny/utils"; -import { createGroupCreateValidation } from "~/crud/contentModelGroup/validation.js"; +import { createGroupCreateValidation } from "~/domain/contentModelGroup/validation.js"; import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupCreateInput } from "~/types/index.js"; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts new file mode 100644 index 00000000000..9fee5644810 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts @@ -0,0 +1,67 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteGroupRepository as RepositoryAbstraction } from "./abstractions.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; +import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; +import { GroupCannotDeleteCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; +import { GroupHasModelsError } from "~/domain/contentModelGroup/errors.js"; +import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * DeleteGroupRepository - Validates and performs group deletion. + * + * Responsibilities: + * - Check if group is plugin-based (cannot delete) + * - Check if models reference this group (cannot delete) + * - Persist deletion to storage + * - Clear GroupCache after successful deletion + */ +class DeleteGroupRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private groupCache: GroupCache.Interface, + private pluginGroupsProvider: PluginGroupsProvider.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute(group: CmsGroup): Promise> { + try { + // Check if this is a plugin-based group (cannot be deleted) + const pluginGroups = await this.pluginGroupsProvider.getGroups(); + const isPluginGroup = pluginGroups.some(pg => pg.slug === group.slug); + + if (isPluginGroup) { + return Result.fail(new GroupCannotDeleteCodeDefinedError(group.id)); + } + + // Check if any models reference this group + const models = await this.storageOperations.models.list({ + where: { + tenant: group.tenant + } + }); + + const items = models.filter(model => model.group.id === group.id); + if (items.length > 0) { + return Result.fail(new GroupHasModelsError()); + } + + // Perform deletion + await this.storageOperations.groups.delete({ group }); + + // Clear cache + this.groupCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new GroupStorageError(error as Error)); + } + } +} + +export const DeleteGroupRepository = createImplementation({ + abstraction: RepositoryAbstraction, + implementation: DeleteGroupRepositoryImpl, + dependencies: [GroupCache, PluginGroupsProvider, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts new file mode 100644 index 00000000000..9b79c52bef8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts @@ -0,0 +1,97 @@ +import { Result } from "@webiny/feature/api"; +import { createImplementation } from "@webiny/feature/api"; +import { DeleteGroupUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { DeleteGroupRepository } from "./abstractions.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { GroupBeforeDeleteEvent } from "./events.js"; +import { GroupAfterDeleteEvent } from "./events.js"; +import { GroupDeleteErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteGroupUseCase - Orchestrates group deletion. + * + * Responsibilities: + * - Fetch original group + * - Access control checks + * - Publish before event + * - Delegate to repository + * - Publish after event or error event + */ +class DeleteGroupUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: DeleteGroupRepository.Interface, + private getGroupUseCase: GetGroupUseCase.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(groupId: string): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessGroup({ rwd: "d" }); + if (!canAccess) { + return Result.fail(new NotAuthorizedError()); + } + + // Fetch original group + const getResult = await this.getGroupUseCase.execute(groupId); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + const group = getResult.value; + + // Access control check on group + const canAccessGroup = await this.accessControl.canAccessGroup({ group }); + if (!canAccessGroup) { + return Result.fail(new NotAuthorizedError()); + } + + try { + // Publish before event + await this.eventPublisher.publish( + new GroupBeforeDeleteEvent({ + group + }) + ); + + // Persist via repository + const result = await this.repository.execute(group); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new GroupDeleteErrorEvent({ + group, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish( + new GroupAfterDeleteEvent({ + group + }) + ); + + return Result.ok(); + } catch (error) { + // Publish error event for unexpected errors + await this.eventPublisher.publish( + new GroupDeleteErrorEvent({ + group, + error: error as Error + }) + ); + return Result.fail(error as UseCaseAbstraction.Error); + } + } +} + +export const DeleteGroupUseCase = createImplementation({ + abstraction: UseCaseAbstraction, + implementation: DeleteGroupUseCaseImpl, + dependencies: [EventPublisher, DeleteGroupRepository, GetGroupUseCase, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts new file mode 100644 index 00000000000..f5d80e91f83 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts @@ -0,0 +1,54 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsGroup } from "~/types/index.js"; +import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupCannotDeleteCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupHasModelsError } from "~/domain/contentModelGroup/errors.js"; +import type { NotAuthorizedError } from "~/utils/errors.js"; + +/** + * DeleteGroup Use Case + */ +export interface IDeleteGroupUseCase { + execute(groupId: string): Promise>; +} + +export interface IDeleteGroupUseCaseErrors { + notFound: GroupNotFoundError; + notAuthorized: NotAuthorizedError; + repository: RepositoryError; +} + +type UseCaseError = IDeleteGroupUseCaseErrors[keyof IDeleteGroupUseCaseErrors]; + +export const DeleteGroupUseCase = createAbstraction("DeleteGroupUseCase"); + +export namespace DeleteGroupUseCase { + export type Interface = IDeleteGroupUseCase; + export type Error = UseCaseError; +} + +/** + * DeleteGroupRepository - Validates and persists group deletion. + */ +export interface IDeleteGroupRepository { + execute(group: CmsGroup): Promise>; +} + +export interface IDeleteGroupRepositoryErrors { + cannotDelete: GroupCannotDeleteCodeDefinedError; + hasModels: GroupHasModelsError; + storage: GroupStorageError; +} + +type RepositoryError = IDeleteGroupRepositoryErrors[keyof IDeleteGroupRepositoryErrors]; + +export const DeleteGroupRepository = createAbstraction( + "DeleteGroupRepository" +); + +export namespace DeleteGroupRepository { + export type Interface = IDeleteGroupRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts new file mode 100644 index 00000000000..840c7ee2082 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts @@ -0,0 +1,80 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsGroup } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface GroupBeforeDeletePayload { + group: CmsGroup; +} + +export interface GroupAfterDeletePayload { + group: CmsGroup; +} + +export interface GroupDeleteErrorPayload { + group: CmsGroup; + error: Error; +} + +/** + * GroupBeforeDeleteEvent - Published before deleting a group + */ +export class GroupBeforeDeleteEvent extends DomainEvent { + eventType = "Cms/Group/BeforeDelete" as const; + + getHandlerAbstraction() { + return GroupBeforeDeleteHandler; + } +} + +export const GroupBeforeDeleteHandler = createAbstraction>( + "GroupBeforeDeleteHandler" +); + +export namespace GroupBeforeDeleteHandler { + export type Interface = IEventHandler; + export type Event = GroupBeforeDeleteEvent; +} + +/** + * GroupAfterDeleteEvent - Published after deleting a group + */ +export class GroupAfterDeleteEvent extends DomainEvent { + eventType = "Cms/Group/AfterDelete" as const; + + getHandlerAbstraction() { + return GroupAfterDeleteHandler; + } +} + +export const GroupAfterDeleteHandler = createAbstraction>( + "GroupAfterDeleteHandler" +); + +export namespace GroupAfterDeleteHandler { + export type Interface = IEventHandler; + export type Event = GroupAfterDeleteEvent; +} + +/** + * GroupDeleteErrorEvent - Published when delete fails + */ +export class GroupDeleteErrorEvent extends DomainEvent { + eventType = "Cms/Group/DeleteError" as const; + + getHandlerAbstraction() { + return GroupDeleteErrorHandler; + } +} + +export const GroupDeleteErrorHandler = createAbstraction>( + "GroupDeleteErrorHandler" +); + +export namespace GroupDeleteErrorHandler { + export type Interface = IEventHandler; + export type Event = GroupDeleteErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/feature.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/feature.ts new file mode 100644 index 00000000000..887c7a9d9a1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteGroupUseCase } from "./DeleteGroupUseCase.js"; +import { DeleteGroupRepository } from "./DeleteGroupRepository.js"; + +/** + * DeleteGroup Feature + * + * Provides functionality for deleting content model groups. + * Prevents deletion of plugin-based groups and groups with models. + */ +export const DeleteGroupFeature = createFeature({ + name: "DeleteGroup", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(DeleteGroupUseCase); + + // Register repository in singleton scope (shared instance) + container.register(DeleteGroupRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/index.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/index.ts new file mode 100644 index 00000000000..4dd6c31d9a8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts index 210c596c627..7ec15cab730 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts @@ -12,7 +12,7 @@ import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import { NotAuthorizedError } from "~/utils/errors.js"; import { createZodError } from "@webiny/utils"; -import { createGroupUpdateValidation } from "~/crud/contentModelGroup/validation.js"; +import { createGroupUpdateValidation } from "~/domain/contentModelGroup/validation.js"; import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupUpdateInput } from "~/types/index.js"; From c74fc986f6b1cf87395b804f599c2d4361b18bba Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 13 Nov 2025 15:44:04 +0100 Subject: [PATCH 16/71] wip: migrate content group model domain --- .../contentAPI/contentEntry.withId.test.ts | 4 +- .../contentAPI/contentModel.crud.test.ts | 8 +-- .../contentAPI/contentModelGroup.crud.test.ts | 25 ++++---- .../contentAPI/resolvers.apiKey.read.test.ts | 8 +-- .../contentAPI/resolvers.manage.test.ts | 12 ++-- .../contentAPI/resolvers.read.test.ts | 16 ++--- .../security/basePermissions.test.ts | 16 ++--- .../security/contentEntries/delete.test.ts | 1 + .../security/contentEntries/write.test.ts | 2 + .../contentModelGroups/delete.test.ts | 1 + .../security/contentModelGroups/write.test.ts | 2 + .../__tests__/contentAPI/security/utils.ts | 2 +- .../src/crud/AccessControl/AccessControl.ts | 54 ++++++++-------- .../src/domain/contentEntry/errors.ts | 63 +++++-------------- .../src/domain/contentModel/errors.ts | 12 ++-- .../src/domain/contentModelGroup/errors.ts | 43 ++++++++----- .../CreateEntry/CreateEntryUseCase.ts | 6 +- .../contentEntry/CreateEntry/abstractions.ts | 4 +- .../CreateEntryRevisionFromUseCase.ts | 6 +- .../CreateEntryRevisionFrom/abstractions.ts | 4 +- .../DeleteEntry/DeleteEntryUseCase.ts | 6 +- .../DeleteEntry/MoveEntryToBinUseCase.ts | 6 +- .../contentEntry/DeleteEntry/abstractions.ts | 4 +- .../decorators/ForceDeleteDecorator.ts | 2 +- .../DeleteEntryRevisionUseCase.ts | 6 +- .../DeleteEntryRevision/abstractions.ts | 4 +- .../DeleteMultipleEntriesUseCase.ts | 4 +- .../DeleteMultipleEntries/abstractions.ts | 4 +- .../GetEntriesByIds/GetEntriesByIdsUseCase.ts | 4 +- .../GetEntriesByIds/abstractions.ts | 4 +- .../contentEntry/GetEntry/abstractions.ts | 4 +- .../contentEntry/GetEntryById/abstractions.ts | 4 +- .../GetLatestEntriesByIdsUseCase.ts | 4 +- .../GetLatestEntriesByIds/abstractions.ts | 4 +- .../GetLatestRevisionByEntryId/BaseUseCase.ts | 4 +- .../abstractions.ts | 4 +- .../BaseUseCase.ts | 4 +- .../abstractions.ts | 4 +- .../GetPublishedEntriesByIdsUseCase.ts | 4 +- .../GetPublishedEntriesByIds/abstractions.ts | 4 +- .../GetRevisionsByEntryIdUseCase.ts | 4 +- .../GetRevisionsByEntryId/abstractions.ts | 4 +- .../GetUniqueFieldValuesUseCase.ts | 4 +- .../GetUniqueFieldValues/abstractions.ts | 4 +- .../ListEntries/ListEntriesUseCase.ts | 4 +- .../contentEntry/ListEntries/abstractions.ts | 4 +- .../MoveEntry/MoveEntryUseCase.ts | 6 +- .../contentEntry/MoveEntry/abstractions.ts | 4 +- .../PublishEntry/PublishEntryUseCase.ts | 6 +- .../contentEntry/PublishEntry/abstractions.ts | 4 +- .../RepublishEntry/RepublishEntryUseCase.ts | 6 +- .../RepublishEntry/abstractions.ts | 4 +- .../RestoreEntryFromBinUseCase.ts | 6 +- .../RestoreEntryFromBin/abstractions.ts | 4 +- .../UnpublishEntry/UnpublishEntryUseCase.ts | 6 +- .../UnpublishEntry/abstractions.ts | 4 +- .../UpdateEntry/UpdateEntryUseCase.ts | 6 +- .../contentEntry/UpdateEntry/abstractions.ts | 4 +- .../ValidateEntry/ValidateEntryUseCase.ts | 6 +- .../ValidateEntry/abstractions.ts | 4 +- .../CreateGroup/CreateGroupRepository.ts | 26 ++++---- .../CreateGroup/CreateGroupUseCase.ts | 18 +++--- .../CreateGroup/abstractions.ts | 15 ++--- .../DeleteGroup/DeleteGroupUseCase.ts | 6 +- .../DeleteGroup/abstractions.ts | 9 ++- .../GetGroup/GetGroupUseCase.ts | 4 +- .../GetGroup/abstractions.ts | 5 +- .../ListGroups/ListGroupsUseCase.ts | 4 +- .../ListGroups/abstractions.ts | 11 ++-- .../UpdateGroup/UpdateGroupRepository.ts | 6 +- .../UpdateGroup/UpdateGroupUseCase.ts | 12 ++-- .../UpdateGroup/abstractions.ts | 7 +-- packages/api-headless-cms/src/types/types.ts | 6 +- 73 files changed, 279 insertions(+), 317 deletions(-) diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts index 44e754ef114..80eeb986620 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts @@ -187,7 +187,7 @@ describe("Content entry with user defined ID", () => { updateCategory: { data: null, error: { - code: "CONTENT_ENTRY_LOCKED", + code: "Cms/Entry/Locked", message: "Cannot update entry because it's locked.", data: null } @@ -254,7 +254,7 @@ describe("Content entry with user defined ID", () => { updateCategory: { data: null, error: { - code: "CONTENT_ENTRY_LOCKED", + code: "Cms/Entry/Locked", message: "Cannot update entry because it's locked.", data: null } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts index e452ac99c1c..22c218835f0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts @@ -22,12 +22,12 @@ const createPermissions = ({ models, groups }: { models?: string[]; groups?: str { name: "cms.contentModelGroup", rwd: "rwd", - groups: groups ? { "en-US": groups } : undefined + groups: groups ?? undefined }, { name: "cms.contentModel", rwd: "rwd", - models: models ? { "en-US": models } : undefined + models: models ?? undefined }, { name: "cms.endpoint.read" @@ -37,10 +37,6 @@ const createPermissions = ({ models, groups }: { models?: string[]; groups?: str }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US"] } ]; diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts index 7ce7319426d..b3dd21835d5 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModelGroup.crud.test.ts @@ -37,7 +37,7 @@ const createPermissions = (groups: string[]) => [ { name: "cms.contentModelGroup", rwd: "rwd", - groups: groups ? { "en-US": groups } : undefined + groups: groups ?? undefined }, { name: "cms.endpoint.read" @@ -47,10 +47,6 @@ const createPermissions = (groups: string[]) => [ }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US"] } ]; @@ -188,7 +184,7 @@ describe("Group crud test", () => { data: null, error: { message: `Group "nonExistingId" was not found!`, - code: "GROUP_NOT_FOUND", + code: "Cms/ModelGroup/NotFound", data: null } } @@ -210,7 +206,7 @@ describe("Group crud test", () => { data: null, error: { message: `Group "nonExistingIdUpdate" was not found!`, - code: "GROUP_NOT_FOUND", + code: "Cms/ModelGroup/NotFound", data: null } } @@ -228,7 +224,7 @@ describe("Group crud test", () => { data: null, error: { message: `Group "nonExistingIdDelete" was not found!`, - code: "GROUP_NOT_FOUND", + code: "Cms/ModelGroup/NotFound", data: null } } @@ -251,7 +247,7 @@ describe("Group crud test", () => { data: null, error: { message: `Validation failed.`, - code: "VALIDATION_FAILED_INVALID_FIELDS", + code: "Cms/ModelGroup/ValidationFailed", data: { invalidFields: { name: expect.any(Object) @@ -277,7 +273,7 @@ describe("Group crud test", () => { data: null, error: { message: `Validation failed.`, - code: "VALIDATION_FAILED_INVALID_FIELDS", + code: "Cms/ModelGroup/ValidationFailed", data: { invalidFields: { icon: expect.any(Object) @@ -303,7 +299,7 @@ describe("Group crud test", () => { data: null, error: { message: `Validation failed.`, - code: "VALIDATION_FAILED_INVALID_FIELDS", + code: "Cms/ModelGroup/ValidationFailed", data: { invalidFields: { name: expect.any(Object), @@ -340,7 +336,7 @@ describe("Group crud test", () => { data: null, error: { message: `Group with the slug "content-model-group" already exists.`, - code: "SLUG_ALREADY_EXISTS", + code: "Cms/ModelGroup/SlugTaken", data: expect.any(Object) } } @@ -349,7 +345,7 @@ describe("Group crud test", () => { }); test("list specific content model groups", async () => { - // Create few content model groups + // Create several content model groups const prefixes = Array.from(Array(TestHelperEnum.MODELS_AMOUNT).keys()).map(prefix => { return createContentModelGroupPrefix(prefix); }); @@ -380,9 +376,10 @@ describe("Group crud test", () => { } // Create listGroups query with permission for only specific groups + const localPermissions = createPermissions([groups[0]]); const { listContentModelGroupsQuery: listGroups } = useGraphQLHandler({ path: "manage", - permissions: createPermissions([groups[0]]) + permissions: localPermissions }); const [response] = await listGroups(); diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts index d90262572d1..09ff69e6878 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts @@ -239,7 +239,7 @@ describe("READ - resolvers - api key", () => { getCategory: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' } } @@ -281,7 +281,7 @@ describe("READ - resolvers - api key", () => { listCategories: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' }, meta: null @@ -331,7 +331,7 @@ describe("READ - resolvers - api key", () => { getCategory: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' } } @@ -380,7 +380,7 @@ describe("READ - resolvers - api key", () => { listCategories: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' }, meta: null diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts index 9616695e74c..ca06df9d1ee 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts @@ -22,12 +22,12 @@ const createPermissions = ({ groups, models }: { groups?: string[]; models?: str { name: "cms.contentModelGroup", rwd: "r", - groups: groups ? { "en-US": groups } : undefined + groups }, { name: "cms.contentModel", rwd: "r", - models: models ? { "en-US": models } : undefined + models }, { name: "cms.contentEntry", @@ -35,10 +35,6 @@ const createPermissions = ({ groups, models }: { groups?: string[]; models?: str }, { name: "cms.endpoint.manage" - }, - { - name: "content.i18n", - locales: ["en-US"] } ]; @@ -76,7 +72,7 @@ describe("MANAGE - Resolvers", () => { const { data, error } = createCMG.data.createContentModelGroup; if (data) { contentModelGroup = data; - } else if (error.code !== "SLUG_ALREADY_EXISTS") { + } else if (error.code !== "Cms/ModelGroup/SlugTaken") { throw new WebinyError(error.message, error.code); } @@ -195,7 +191,7 @@ describe("MANAGE - Resolvers", () => { expect(response.data.getCategory.data).toEqual(null); expect(response.data.getCategory.error).toEqual({ - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.', data: null }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts index 5b99c5fc0c9..ad903e19afe 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts @@ -14,12 +14,12 @@ const createPermissions = ({ groups, models }: { groups?: string[]; models?: str { name: "cms.contentModelGroup", rwd: "r", - groups: groups ? { "en-US": groups } : undefined + groups }, { name: "cms.contentModel", rwd: "r", - models: models ? { "en-US": models } : undefined + models }, { name: "cms.contentEntry", @@ -30,10 +30,6 @@ const createPermissions = ({ groups, models }: { groups?: string[]; models?: str }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US"] } ]; @@ -81,7 +77,7 @@ vi.setConfig({ testTimeout: 100_000 }); -describe("READ - Resolvers", () => { +describe.sequential("READ - Resolvers", () => { let contentModelGroup: CmsGroup; const manageOpts = { path: "manage/en-US" }; @@ -213,7 +209,7 @@ describe("READ - Resolvers", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: "Entry was not found!", data: null } @@ -343,7 +339,7 @@ describe("READ - Resolvers", () => { getCategory: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' } } @@ -378,7 +374,7 @@ describe("READ - Resolvers", () => { getCategory: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "category" entries.' } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/basePermissions.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/basePermissions.test.ts index e20f63a14f7..e0de0ef7c81 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/basePermissions.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/basePermissions.test.ts @@ -135,9 +135,7 @@ describe("Content Groups / Models / Entries - Base Permissions Checks", () => { own: false, rwd: "r", pw: "", - groups: { - "en-US": [modelGroup1.data.createContentModelGroup.data.id] - } + groups: [modelGroup1.data.createContentModelGroup.data.id] }, { _src: "x", name: "cms.contentEntry", own: false, rwd: "r", pw: "" } ] @@ -154,9 +152,7 @@ describe("Content Groups / Models / Entries - Base Permissions Checks", () => { own: false, rwd: "r", pw: "", - groups: { - "en-US": [modelGroup2.data.createContentModelGroup.data.id] - } + groups: [modelGroup2.data.createContentModelGroup.data.id] }, { _src: "y", name: "cms.contentEntry", own: false, rwd: "r", pw: "" } ] @@ -297,9 +293,7 @@ describe("Content Groups / Models / Entries - Base Permissions Checks", () => { own: false, rwd: "rwd", pw: "", - models: { - "en-US": ["testEntry1"] - } + models: ["testEntry1"] }, { _src: "x", name: "cms.contentEntry", own: true, rwd: "rwd", pw: "" } ] @@ -316,9 +310,7 @@ describe("Content Groups / Models / Entries - Base Permissions Checks", () => { own: false, rwd: "rwd", pw: "", - models: { - "en-US": ["testEntry2"] - } + models: ["testEntry2"] }, { _src: "y", name: "cms.contentEntry", own: true, rwd: "rwd", pw: "" } ] diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/delete.test.ts index 113abae6293..247ed22c646 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/delete.test.ts @@ -34,6 +34,7 @@ describe("Delete Permissions Checks", () => { }); expectNotAuthorized(failedEntryDeletion, { + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "testModel" entries.' }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts index db0dd92eae3..87ded230442 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts @@ -29,6 +29,7 @@ describe("Write Permissions Checks", () => { const failedCreateTestEntryResponse = await manageApiB.createTestEntry(); expectNotAuthorized(failedCreateTestEntryResponse, { + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "testModel" entries.' }); @@ -74,6 +75,7 @@ describe("Write Permissions Checks", () => { }); expectNotAuthorized(failedUpdateTestEntryResponse, { + code: "Cms/Entry/NotAuthorized", message: 'Not allowed to access "testModel" entries.' }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/delete.test.ts index a30dc5c737c..5c16e24cde3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/delete.test.ts @@ -27,6 +27,7 @@ describe("Delete Permissions Checks", () => { }); expectNotAuthorized(modelGroupDeletion.data.deleteContentModelGroup, { + code: "Cms/ModelGroup/NotAuthorized", message: "Not allowed to access content model groups." }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/write.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/write.test.ts index 8950c0bbef1..8288be3ab13 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/write.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentModelGroups/write.test.ts @@ -23,6 +23,7 @@ describe("Write Permissions Checks", () => { }); expectNotAuthorized(modelGroup.data.createContentModelGroup, { + code: "Cms/ModelGroup/NotAuthorized", message: "Not allowed to access content model groups." }); @@ -73,6 +74,7 @@ describe("Write Permissions Checks", () => { }); expectNotAuthorized(notUpdatedModelGroup.data.updateContentModelGroup, { + code: "Cms/ModelGroup/NotAuthorized", message: "Not allowed to access content model groups." }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/utils.ts b/packages/api-headless-cms/__tests__/contentAPI/security/utils.ts index 7765be92cc9..29ab1c507e0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/utils.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/utils.ts @@ -80,7 +80,7 @@ export const expectNotAuthorized = ( expect(testData).toEqual({ data: null, error: { - code: "NOT_AUTHORIZED", + code: errorData?.code ?? "NOT_AUTHORIZED", message: errorData?.message ?? "Not authorized!", data: null } diff --git a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts index accdb8b5b1a..d702c54afe7 100644 --- a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts +++ b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts @@ -34,7 +34,7 @@ interface CanAccessModelParams extends GetModelsAccessControlListParams { } interface GetEntriesAccessControlListParams { - model: Pick; + model: Pick; entry?: Pick; } @@ -116,24 +116,6 @@ export class AccessControl { return true; } - async ensureCanAccessGroup(params: CanAccessGroupParams = {}) { - const canAccess = await this.canAccessGroup(params); - if (canAccess) { - return; - } - - if ("group" in params) { - let groupName = "(could not determine name)"; - if (params.group?.name) { - groupName = `"${params.group.name}"`; - } - - throw new NotAuthorizedError(`Not allowed to access content model group ${groupName}.`); - } - - throw new NotAuthorizedError(`Not allowed to access content model groups.`); - } - async canAccessNonOwnedGroups(params: GetGroupsAccessControlListParams) { const acl = await this.getGroupsAccessControlList(params); return acl.some(ace => ace.canAccessNonOwned); @@ -176,6 +158,24 @@ export class AccessControl { }); } + if (groupsPermissions.groups) { + if ("group" in params) { + const { group } = params; + if (!group) { + continue; + } + + const { groups } = groupsPermissions; + if (!Array.isArray(groups)) { + continue; + } + + if (!groups.includes(group.id)) { + continue; + } + } + } + acl.push({ rwd: groupsPermissions.rwd as string, canAccessNonOwned: true, @@ -309,11 +309,11 @@ export class AccessControl { continue; } - if (!Array.isArray(groupsPermissions.groups[model.locale])) { + if (!Array.isArray(groupsPermissions.groups)) { continue; } - if (!groupsPermissions.groups[model.locale].includes(model.group.id)) { + if (!groupsPermissions.groups.includes(model.group.id)) { continue; } } @@ -367,11 +367,11 @@ export class AccessControl { continue; } - if (!Array.isArray(models[params.model.locale])) { + if (!Array.isArray(models)) { continue; } - if (!models[params.model.locale].includes(params.model.modelId)) { + if (!models.includes(params.model.modelId)) { continue; } } @@ -522,11 +522,11 @@ export class AccessControl { if (groupPermissions.groups) { const { groups } = groupPermissions; - if (!Array.isArray(groups[model.locale])) { + if (!Array.isArray(groups)) { continue; } - if (!groups[model.locale].includes(model.group.id)) { + if (!groups.includes(model.group.id)) { continue; } } @@ -551,11 +551,11 @@ export class AccessControl { } if (relatedModelsPermissions.models) { - if (!Array.isArray(relatedModelsPermissions.models[model.locale])) { + if (!Array.isArray(relatedModelsPermissions.models)) { continue; } - if (!relatedModelsPermissions.models[model.locale].includes(model.modelId)) { + if (!relatedModelsPermissions.models.includes(model.modelId)) { continue; } } diff --git a/packages/api-headless-cms/src/domain/contentEntry/errors.ts b/packages/api-headless-cms/src/domain/contentEntry/errors.ts index 89ce2f5c747..42794718f9f 100644 --- a/packages/api-headless-cms/src/domain/contentEntry/errors.ts +++ b/packages/api-headless-cms/src/domain/contentEntry/errors.ts @@ -1,7 +1,8 @@ import { BaseError } from "@webiny/feature/api"; +import type { CmsEntry, CmsModel } from "~/types/index.js"; export class EntryNotFoundError extends BaseError { - override readonly code = "ENTRY_NOT_FOUND" as const; + override readonly code = "Cms/Entry/NotFound" as const; constructor(id?: string) { super({ @@ -10,18 +11,8 @@ export class EntryNotFoundError extends BaseError { } } -export class EntryNotAccessibleError extends BaseError { - override readonly code = "ENTRY_NOT_ACCESSIBLE" as const; - - constructor(id: string) { - super({ - message: `Entry "${id}" was not found!` - }); - } -} - export class EntryStorageError extends BaseError { - override readonly code = "ENTRY_STORAGE_ERROR" as const; + override readonly code = "Cms/Entry/StorageError" as const; constructor(error: Error) { super({ @@ -31,7 +22,7 @@ export class EntryStorageError extends BaseError { } export class EntryValidationError extends BaseError { - override readonly code = "ENTRY_VALIDATION_ERROR" as const; + override readonly code = "Cms/Entry/ValidationError" as const; constructor(message: string) { super({ @@ -40,52 +31,30 @@ export class EntryValidationError extends BaseError { } } -export class EntryAlreadyPublishedError extends BaseError { - override readonly code = "ENTRY_ALREADY_PUBLISHED" as const; - - constructor(id: string) { - super({ - message: `Entry "${id}" is already published!` - }); - } -} - -export class EntryNotPublishedError extends BaseError { - override readonly code = "ENTRY_NOT_PUBLISHED" as const; +export class EntryLockedError extends BaseError { + override readonly code = "Cms/Entry/Locked" as const; - constructor(id: string) { + constructor() { super({ - message: `Entry "${id}" is not published!` + message: `Cannot update entry because it's locked.` }); } } -export class EntryInBinError extends BaseError { - override readonly code = "ENTRY_IN_BIN" as const; +export class ContentEntryNotAuthorizedError extends BaseError { + override readonly code = "Cms/Entry/NotAuthorized" as const; - constructor(id: string) { + constructor(message?: string) { super({ - message: `Entry "${id}" is in bin!` + message: message || "Not authorized!" }); } -} - -export class EntryNotInBinError extends BaseError { - override readonly code = "ENTRY_NOT_IN_BIN" as const; - constructor(id: string) { - super({ - message: `Entry "${id}" is not in bin!` - }); + static fromModel(model: CmsModel): ContentEntryNotAuthorizedError { + return new ContentEntryNotAuthorizedError(`Not allowed to access "${model.modelId}" entries.`); } -} -export class EntryLockedError extends BaseError { - override readonly code = "CONTENT_ENTRY_LOCKED" as const; - - constructor() { - super({ - message: `Cannot update entry because it's locked.` - }); + static fromEntry(entry: CmsEntry): ContentEntryNotAuthorizedError { + return new ContentEntryNotAuthorizedError(`Not allowed to access entry "${entry.entryId}".`); } } diff --git a/packages/api-headless-cms/src/domain/contentModel/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts index 0ef93745b18..111956623c9 100644 --- a/packages/api-headless-cms/src/domain/contentModel/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModel/errors.ts @@ -1,7 +1,7 @@ import { BaseError } from "@webiny/feature/api"; export class ModelNotFoundError extends BaseError { - override readonly code = "MODEL_NOT_FOUND" as const; + override readonly code = "Cms/Model/NotFound" as const; constructor(modelId: string) { super({ @@ -11,7 +11,7 @@ export class ModelNotFoundError extends BaseError { } export class ModelAlreadyExistsError extends BaseError { - override readonly code = "MODEL_ALREADY_EXISTS" as const; + override readonly code = "Cms/Model/AlreadyExists" as const; constructor(modelId: string) { super({ @@ -21,7 +21,7 @@ export class ModelAlreadyExistsError extends BaseError { } export class ModelStorageError extends BaseError { - override readonly code = "MODEL_STORAGE_ERROR" as const; + override readonly code = "Cms/Model/StorageError" as const; constructor(error: Error) { super({ @@ -31,7 +31,7 @@ export class ModelStorageError extends BaseError { } export class ModelValidationError extends BaseError { - override readonly code = "MODEL_VALIDATION_ERROR" as const; + override readonly code = "Cms/Model/ValidationError" as const; constructor(message: string) { super({ @@ -41,7 +41,7 @@ export class ModelValidationError extends BaseError { } export class ModelCannotUpdateCodeDefinedError extends BaseError { - override readonly code = "MODEL_CANNOT_UPDATE_CODE_DEFINED" as const; + override readonly code = "Cms/Model/CannotUpdateCodeModel" as const; constructor(modelId: string) { super({ @@ -51,7 +51,7 @@ export class ModelCannotUpdateCodeDefinedError extends BaseError { } export class ModelCannotDeleteCodeDefinedError extends BaseError { - override readonly code = "MODEL_CANNOT_DELETE_CODE_DEFINED" as const; + override readonly code = "Cms/Model/CannotDeleteCodeModel" as const; constructor(modelId: string) { super({ diff --git a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts index cc86ab31763..bc4f83ebafe 100644 --- a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts @@ -1,7 +1,8 @@ import { BaseError } from "@webiny/feature/api"; +import type { OutputErrors } from "@webiny/utils/createZodError.js"; export class GroupNotFoundError extends BaseError { - override readonly code = "GROUP_NOT_FOUND" as const; + override readonly code = "Cms/ModelGroup/NotFound" as const; constructor(groupId: string) { super({ @@ -10,18 +11,18 @@ export class GroupNotFoundError extends BaseError { } } -export class GroupAlreadyExistsError extends BaseError { - override readonly code = "GROUP_ALREADY_EXISTS" as const; +export class GroupSlugTakenError extends BaseError { + override readonly code = "Cms/ModelGroup/SlugTaken" as const; - constructor(groupId: string) { + constructor(slug: string) { super({ - message: `Group "${groupId}" already exists!` + message: `Group with the slug "${slug}" already exists.` }); } } export class GroupStorageError extends BaseError { - override readonly code = "GROUP_STORAGE_ERROR" as const; + override readonly code = "Cms/ModelGroup/StorageError" as const; constructor(error: Error) { super({ @@ -30,18 +31,20 @@ export class GroupStorageError extends BaseError { } } -export class GroupValidationError extends BaseError { - override readonly code = "GROUP_VALIDATION_ERROR" as const; +interface ValidationParams { + invalidFields: OutputErrors; +} - constructor(message: string) { - super({ - message - }); +export class GroupValidationError extends BaseError { + override readonly code = "Cms/ModelGroup/ValidationFailed" as const; + + constructor(message: string, invalidFields: OutputErrors) { + super({ message, data: { invalidFields } }); } } export class GroupCannotUpdateCodeDefinedError extends BaseError { - override readonly code = "GROUP_CANNOT_UPDATE_CODE_DEFINED" as const; + override readonly code = "Cms/ModelGroup/CannotUpdateCodeGroup" as const; constructor(groupId: string) { super({ @@ -51,7 +54,7 @@ export class GroupCannotUpdateCodeDefinedError extends BaseError { } export class GroupCannotDeleteCodeDefinedError extends BaseError { - override readonly code = "GROUP_CANNOT_DELETE_CODE_DEFINED" as const; + override readonly code = "Cms/ModelGroup/CannotDeleteCodeGroup" as const; constructor(groupId: string) { super({ @@ -61,7 +64,7 @@ export class GroupCannotDeleteCodeDefinedError extends BaseError { } export class GroupHasModelsError extends BaseError { - override readonly code = "GROUP_HAS_MODELS" as const; + override readonly code = "Cms/ModelGroup/HasModels" as const; constructor() { super({ @@ -69,3 +72,13 @@ export class GroupHasModelsError extends BaseError { }); } } + +export class GroupNotAuthorizedError extends BaseError { + override readonly code = "Cms/ModelGroup/NotAuthorized" as const; + + constructor(message?: string) { + super({ + message: message || "Not allowed to access content model groups." + }); + } +} diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts index e892cb28f9e..2a171c9bc24 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts @@ -11,7 +11,7 @@ import type { CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { createEntryData } from "~/crud/contentEntry/entryDataFactories/createEntryData.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; @@ -44,7 +44,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } try { @@ -67,7 +67,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromEntry(entry)); + return Result.fail(ContentEntryNotAuthorizedError.fromEntry(entry)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts index dadd03fa9b0..0e776365fc7 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * CreateEntry Use Case @@ -16,7 +16,7 @@ export interface ICreateEntryUseCase { } export interface ICreateEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; validation: EntryValidationError; repository: RepositoryError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts index 61608f5dc70..23203088273 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts @@ -19,7 +19,7 @@ import { EntryRevisionAfterCreateEvent, EntryRevisionCreateErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { parseIdentifier } from "@webiny/utils"; import { createEntryRevisionFromData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -58,7 +58,7 @@ class CreateEntryRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the source entry @@ -102,7 +102,7 @@ class CreateEntryRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } try { diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts index aaffb359393..c87793abcd8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts @@ -11,7 +11,7 @@ import type { EntryValidationError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * CreateEntryRevisionFrom Use Case - Creates a new revision from an existing entry. @@ -26,7 +26,7 @@ export interface ICreateEntryRevisionFromUseCase { } export interface ICreateEntryRevisionFromUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; storage: EntryStorageError; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts index ccbd63279ca..8fb90e2bd4a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts @@ -7,7 +7,7 @@ import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/co import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryUseCase - Orchestrates permanent deletion of an entry. @@ -41,7 +41,7 @@ class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID @@ -61,7 +61,7 @@ class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new ContentEntryNotAuthorizedError()); } try { diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts index 5e81e16c8e6..23a7fac70bf 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts @@ -8,7 +8,7 @@ import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLa import type { CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; @@ -38,7 +38,7 @@ class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID @@ -58,7 +58,7 @@ class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new ContentEntryNotAuthorizedError()); } // Create the deleted entry data diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts index f2a610b5124..decefb1e3a0 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntry Use Case - Permanently deletes an entry from the database. @@ -17,7 +17,7 @@ export interface IDeleteEntryUseCase { } export interface IDeleteEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts index fd9e0f8f0d3..8876c1ff338 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts @@ -28,7 +28,7 @@ class ForceDeleteDecoratorImpl implements DeleteEntryUseCase.Interface { const result = await this.decoratee.execute(model, id, options); - if (force && result.isFail() && result.error.code === "ENTRY_NOT_FOUND") { + if (force && result.isFail() && result.error.code === "Cms/Entry/NotFound") { const { id: entryId } = parseIdentifier(id); try { diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index e3b11238dcb..10d2fc1b4ef 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -15,7 +15,7 @@ import { EntryRevisionDeleteErrorEvent } from "./events.js"; import { parseIdentifier } from "@webiny/utils"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryRevisionUseCase - Orchestrates deletion of a specific entry revision. @@ -48,7 +48,7 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } const { id: entryId, version } = parseIdentifier(revisionId); @@ -69,7 +69,7 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new ContentEntryNotAuthorizedError()); } // Get the latest revision diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts index 6de34d80a96..a680f6c733a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryRevision Use Case - Deletes a specific revision of an entry. @@ -13,7 +13,7 @@ export interface IDeleteEntryRevisionUseCase { } export interface IDeleteEntryRevisionUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts index 24f92dc6044..9da559d7da5 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts @@ -11,7 +11,7 @@ import { EntryAfterDeleteMultipleEvent, EntryDeleteMultipleErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { parseIdentifier } from "@webiny/utils"; import WebinyError from "@webiny/error"; import { filterAsync } from "~/utils/filterAsync.js"; @@ -67,7 +67,7 @@ class DeleteMultipleEntriesUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Fetch entries using ListEntries use case diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts index 448ba8ef251..7813f090f78 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteMultipleEntries Use Case - Deletes multiple entries at once. @@ -15,7 +15,7 @@ export interface IDeleteMultipleEntriesUseCase { } export interface IDeleteMultipleEntriesUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts index 2d195ef2240..330bd0338c2 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractions.js" import { GetEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntriesByIdsUseCase - Orchestrates fetching entries by IDs with access control. @@ -26,7 +26,7 @@ class GetEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts index ac30651fd0d..90af4d58cf7 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntriesByIds Use Case - Fetches multiple entries by their exact revision IDs. @@ -16,7 +16,7 @@ export interface IGetEntriesByIdsUseCase { } export interface IGetEntriesByIdsUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts index e6b81283010..28aeb6685cb 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntry Use Case - Gets a single entry by query parameters (where + sort). @@ -16,7 +16,7 @@ export interface IGetEntryUseCase { } export interface IGetEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts index ab612bda285..bd496cc8c89 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntryById Use Case - Fetches a single entry by its exact revision ID. @@ -16,7 +16,7 @@ export interface IGetEntryByIdUseCase { } export interface IGetEntryByIdUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts index 06ba3e9afae..360216d358f 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetLatestEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractio import { GetLatestEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetLatestEntriesByIdsUseCase - Orchestrates fetching latest entries by IDs. @@ -26,7 +26,7 @@ class GetLatestEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts index c1deef9c6e8..770a595bc22 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetLatestEntriesByIds Use Case - Fetches latest revisions by entry IDs. @@ -16,7 +16,7 @@ export interface IGetLatestEntriesByIdsUseCase { } export interface IGetLatestEntriesByIdsUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts index d6b3634658b..528359dc76e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts @@ -9,7 +9,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Orchestrates fetching latest revision by entry ID. @@ -32,7 +32,7 @@ class GetLatestRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction.In // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts index 033f13ccef4..9550ea89ff3 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base internal use case - returns entry regardless of deleted state. @@ -21,7 +21,7 @@ export interface IGetLatestRevisionByEntryIdBaseUseCase { } export interface IGetLatestRevisionByEntryIdUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts index c8a1e17bc03..b775981da07 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts @@ -9,7 +9,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Orchestrates fetching previous revision by entry ID and version. @@ -32,7 +32,7 @@ class GetPreviousRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction. // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts index c491f55cbf9..f4262871cb1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base internal use case - returns entry regardless of deleted state. @@ -21,7 +21,7 @@ export interface IGetPreviousRevisionByEntryIdBaseUseCase { } export interface IGetPreviousRevisionByEntryIdUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts index 48bf1b6693a..7a53842f4a3 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetPublishedEntriesByIdsUseCase as UseCaseAbstraction } from "./abstrac import { GetPublishedEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedEntriesByIdsUseCase - Orchestrates fetching published entries by IDs. @@ -26,7 +26,7 @@ class GetPublishedEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interfac // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts index eb0470a18fe..100dc47ba01 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedEntriesByIds Use Case - Fetches published revisions by entry IDs. @@ -16,7 +16,7 @@ export interface IGetPublishedEntriesByIdsUseCase { } export interface IGetPublishedEntriesByIdsUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts index 12f66493b43..3135b9e4efc 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts @@ -4,7 +4,7 @@ import { GetRevisionsByEntryIdUseCase as UseCaseAbstraction } from "./abstractio import { GetRevisionsByEntryIdRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionsByEntryIdUseCase - Orchestrates fetching all revisions for an entry. @@ -26,7 +26,7 @@ class GetRevisionsByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts index 453e1fabbdd..e49d246e872 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionsByEntryId Use Case - Fetches all revisions for a given entry ID. @@ -16,7 +16,7 @@ export interface IGetRevisionsByEntryIdUseCase { } export interface IGetRevisionsByEntryIdUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts index 07bfb27991a..0c43daf4efe 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts @@ -6,7 +6,7 @@ import { GetUniqueFieldValuesParams } from "./abstractions.js"; import { AccessControl, CmsContext } from "~/features/shared/abstractions.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; @@ -26,7 +26,7 @@ class GetUniqueFieldValuesUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.accessControl.ensureCanAccessEntry({ model }); } catch (error) { - if (error instanceof NotAuthorizedError) { + if (error instanceof ContentEntryNotAuthorizedError) { return Result.fail(error); } throw error; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts index e20368ddf32..e3c2c4da0da 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; export interface GetUniqueFieldValuesParams { @@ -27,7 +27,7 @@ export interface IGetUniqueFieldValuesUseCase { } export interface IGetUniqueFieldValuesUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; fieldNotSearchable: FieldNotSearchableError; invalidWhere: InvalidWhereConditionError; diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts index a9df2e0a0a5..384923e0629 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts @@ -11,7 +11,7 @@ import type { CmsEntryValues, CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * ListEntriesUseCase - Base use case for orchestrating entry listing with access control. @@ -35,7 +35,7 @@ class ListEntriesUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } const { where: initialWhere, ...rest } = params || {}; diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts index 02f6dcdae8b..dad5e7c5184 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts @@ -8,7 +8,7 @@ import type { CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base ListEntries Use Case - Internal base use case for listing entries. @@ -22,7 +22,7 @@ export interface IListEntriesUseCase { } export interface IListEntriesUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts index 5bb4f6d12e1..45eba3ffd63 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts @@ -7,7 +7,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforeMoveEvent, EntryAfterMoveEvent, EntryMoveErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -36,7 +36,7 @@ class MoveEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the entry to move @@ -56,7 +56,7 @@ class MoveEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Early return if entry is already in the requested folder diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts index 2515e6d9954..edcd0ef5b37 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -13,7 +13,7 @@ export interface IMoveEntryUseCase { } export interface IMoveEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts index d41bf5617b3..58ef870dbec 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts @@ -13,7 +13,7 @@ import { EntryAfterPublishEvent, EntryPublishErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createPublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -48,7 +48,7 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control (publish permission) const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the entry to publish @@ -68,7 +68,7 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the latest revision for entry-level metadata diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index ea7e96c14b0..170a5160cce 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -13,7 +13,7 @@ export interface IPublishEntryUseCase { } export interface IPublishEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; storage: EntryStorageError; diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts index 5b936192e5e..ed301483915 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts @@ -12,7 +12,7 @@ import { EntryAfterRepublishEvent, EntryRepublishErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createRepublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -44,7 +44,7 @@ class RepublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control (write and publish) const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w", pw: "p" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the entry to republish @@ -65,7 +65,7 @@ class RepublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Prepare entry data for republishing diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts index 4041c66d2dc..2038c3288d1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -14,7 +14,7 @@ export interface IRepublishEntryUseCase { } export interface IRepublishEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts index 7b064dc17fc..e3b5c28adfa 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts @@ -12,7 +12,7 @@ import { EntryAfterRestoreFromBinEvent, EntryRestoreFromBinErrorEvent } from "./events.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; @@ -45,7 +45,7 @@ class RestoreEntryFromBinUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Get the deleted entry to restore by ID @@ -65,7 +65,7 @@ class RestoreEntryFromBinUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Create the restored entry data diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts index 39471ef8e65..dc59d1dffad 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * RestoreEntryFromBin Use Case - Restores a soft-deleted entry from the bin. @@ -13,7 +13,7 @@ export interface IRestoreEntryFromBinUseCase { } export interface IRestoreEntryFromBinUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryStorageError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts index 4090d121213..105b1f1eda6 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts @@ -12,7 +12,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { GetPublishedRevisionByEntryIdUseCase } from "~/features/contentEntry/GetPublishedRevisionByEntryId/index.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { EntryValidationError } from "~/domain/contentEntry/errors.js"; import { createUnpublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; @@ -44,7 +44,7 @@ class UnpublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, pw: "u" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Parse entry ID from revision ID @@ -76,7 +76,7 @@ class UnpublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Transform to unpublish data diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts index 408252adfe8..3c0137b4cae 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts @@ -5,7 +5,7 @@ import type { CmsModel } from "~/types/index.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; import type { EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * UnpublishEntry Use Case @@ -15,7 +15,7 @@ export interface IUnpublishEntryUseCase { } export interface IUnpublishEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; repository: RepositoryError; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts index 54c0d83073e..e3036bb2fe2 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts @@ -16,7 +16,7 @@ import type { UpdateCmsEntryOptionsInput } from "~/types/index.js"; import type { GenericRecord } from "@webiny/api/types.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryLockedError } from "~/domain/contentEntry/errors.js"; import { createUpdateEntryData } from "~/crud/contentEntry/entryDataFactories/createUpdateEntryData.js"; @@ -52,7 +52,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } try { @@ -89,7 +89,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts index dd5241eddd9..aca637f3a15 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts @@ -13,7 +13,7 @@ import type { EntryValidationError, EntryLockedError } from "~/domain/contentEntry/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * UpdateEntry Use Case @@ -29,7 +29,7 @@ export interface IUpdateEntryUseCase { } export interface IUpdateEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; locked: EntryLockedError; validation: EntryValidationError; diff --git a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts index 9067bf40429..7b5ca839aff 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts @@ -4,7 +4,7 @@ import { ValidateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { mapAndCleanUpdatedInputData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { validateModelEntryData } from "~/crud/contentEntry/entryDataValidation.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -34,7 +34,7 @@ class ValidateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } // Map and clean input data @@ -59,7 +59,7 @@ class ValidateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(NotAuthorizedError.fromModel(model)); + return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); } } diff --git a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts index 1a655f540d7..bd13249007a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; @@ -18,7 +18,7 @@ export interface IValidateEntryUseCase { } export interface IValidateEntryUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; getRevisionById: GetRevisionByIdUseCase.Error; } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts index 9c152fff8fe..f0ab42c2089 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { CreateGroupRepository as RepositoryAbstraction } from "./abstractions.js"; import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; -import { GroupAlreadyExistsError } from "~/domain/contentModelGroup/errors.js"; +import { GroupSlugTakenError } from "~/domain/contentModelGroup/errors.js"; import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; @@ -42,23 +42,23 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { id: group.id } }); + if (existingById.length > 0) { - return Result.fail(new GroupAlreadyExistsError(group.id)); + return Result.fail(new GroupSlugTakenError(group.slug)); } } // 2. Generate or validate slug - await this.ensureUniqueSlug(group, tenant.id); + const slugTaken = await this.isSlugTaken(group, tenant.id); + if (slugTaken) { + return Result.fail(new GroupSlugTakenError(group.slug)); + } // 3. Check for plugin group conflicts const pluginGroups = await this.pluginGroupsProvider.getGroups(); const pluginGroupConflict = pluginGroups.find(pg => pg.slug === group.slug); if (pluginGroupConflict) { - return Result.fail( - new GroupAlreadyExistsError( - `Cannot create "${group.slug}" because it's registered via a plugin` - ) - ); + return Result.fail(new GroupSlugTakenError(group.slug)); } // 4. Persist to storage @@ -73,7 +73,7 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { } } - private async ensureUniqueSlug(group: CmsGroup, tenant: string): Promise { + private async isSlugTaken(group: CmsGroup, tenant: string): Promise { // If slug is provided and not empty, validate it if (group.slug && group.slug.trim()) { const existingBySlug = await this.storageOperations.groups.list({ @@ -82,10 +82,8 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { slug: group.slug } }); - if (existingBySlug.length > 0) { - throw new GroupAlreadyExistsError(`Slug "${group.slug}" already exists`); - } - return; + + return existingBySlug.length > 0; } // Generate slug from name @@ -104,6 +102,8 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { // Conflict, append random suffix group.slug = `${baseSlug}-${generateAlphaNumericId(8)}`; } + + return false; } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts index ca964e01e79..9399fff12b0 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts @@ -10,8 +10,10 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { CmsContext } from "~/features/shared/abstractions.js"; -import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { + GroupNotAuthorizedError, + GroupValidationError +} from "~/domain/contentModelGroup/errors.js"; import { createZodError } from "@webiny/utils"; import { mdbid } from "@webiny/utils"; import { createGroupCreateValidation } from "~/domain/contentModelGroup/validation.js"; @@ -39,20 +41,20 @@ class CreateGroupUseCaseImpl implements UseCaseAbstraction.Interface { private cmsContext: CmsContext.Interface ) {} - async execute( - input: CmsGroupCreateInput - ): Promise> { + async execute(input: CmsGroupCreateInput): Promise> { // Initial access control check const canAccess = await this.accessControl.canAccessGroup({ rwd: "w" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Validate input const validationResult = await createGroupCreateValidation().safeParseAsync(input); if (!validationResult.success) { const zodError = createZodError(validationResult.error); - return Result.fail(new GroupValidationError(zodError.message)); + return Result.fail( + new GroupValidationError(zodError.message, zodError.data!.invalidFields) + ); } const data = validationResult.data; @@ -78,7 +80,7 @@ class CreateGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Access control check on created group const canAccessGroup = await this.accessControl.canAccessGroup({ group, rwd: "w" }); if (!canAccessGroup) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } try { diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts index 7c9ff6ee7c9..cb00be65af4 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts @@ -2,10 +2,12 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupCreateInput } from "~/types/index.js"; -import type { GroupAlreadyExistsError } from "~/domain/contentModelGroup/errors.js"; +import { + type GroupSlugTakenError, + GroupNotAuthorizedError +} from "~/domain/contentModelGroup/errors.js"; import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; /** * CreateGroup Use Case @@ -15,7 +17,7 @@ export interface ICreateGroupUseCase { } export interface ICreateGroupUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: GroupNotAuthorizedError; validation: GroupValidationError; repository: RepositoryError; } @@ -37,15 +39,14 @@ export interface ICreateGroupRepository { } export interface ICreateGroupRepositoryErrors { - alreadyExists: GroupAlreadyExistsError; + alreadyExists: GroupSlugTakenError; storage: GroupStorageError; } type RepositoryError = ICreateGroupRepositoryErrors[keyof ICreateGroupRepositoryErrors]; -export const CreateGroupRepository = createAbstraction( - "CreateGroupRepository" -); +export const CreateGroupRepository = + createAbstraction("CreateGroupRepository"); export namespace CreateGroupRepository { export type Interface = ICreateGroupRepository; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts index 9b79c52bef8..772c5e6728e 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupUseCase.ts @@ -8,7 +8,7 @@ import { GroupBeforeDeleteEvent } from "./events.js"; import { GroupAfterDeleteEvent } from "./events.js"; import { GroupDeleteErrorEvent } from "./events.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; +import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; /** * DeleteGroupUseCase - Orchestrates group deletion. @@ -32,7 +32,7 @@ class DeleteGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Initial access control check const canAccess = await this.accessControl.canAccessGroup({ rwd: "d" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Fetch original group @@ -45,7 +45,7 @@ class DeleteGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Access control check on group const canAccessGroup = await this.accessControl.canAccessGroup({ group }); if (!canAccessGroup) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } try { diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts index f5d80e91f83..89426febd00 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts @@ -1,11 +1,11 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; +import type { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; import type { GroupCannotDeleteCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; import type { GroupHasModelsError } from "~/domain/contentModelGroup/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; /** * DeleteGroup Use Case @@ -16,7 +16,7 @@ export interface IDeleteGroupUseCase { export interface IDeleteGroupUseCaseErrors { notFound: GroupNotFoundError; - notAuthorized: NotAuthorizedError; + notAuthorized: GroupNotAuthorizedError; repository: RepositoryError; } @@ -44,9 +44,8 @@ export interface IDeleteGroupRepositoryErrors { type RepositoryError = IDeleteGroupRepositoryErrors[keyof IDeleteGroupRepositoryErrors]; -export const DeleteGroupRepository = createAbstraction( - "DeleteGroupRepository" -); +export const DeleteGroupRepository = + createAbstraction("DeleteGroupRepository"); export namespace DeleteGroupRepository { export type Interface = IDeleteGroupRepository; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts index 17a0f7bc0fc..c7238dddfbf 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupUseCase.ts @@ -3,8 +3,8 @@ import { createImplementation } from "@webiny/feature/api"; import { GetGroupUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetGroupRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; import type { CmsGroup } from "~/types/index.js"; +import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; /** * GetGroupUseCase - Retrieves a single content model group by ID. @@ -24,7 +24,7 @@ class GetGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Initial access control check (no specific group yet) const canAccess = await this.accessControl.canAccessGroup(); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Repository uses GroupCache to fetch all groups, then filters by ID diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts index a3a94ca8589..5185ccd2836 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts @@ -1,9 +1,8 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; -import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; /** * GetGroup Use Case @@ -14,7 +13,7 @@ export interface IGetGroupUseCase { export interface IGetGroupUseCaseErrors { notFound: GroupNotFoundError; - notAuthorized: NotAuthorizedError; + notAuthorized: GroupNotAuthorizedError; repository: RepositoryError; } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts index c8dd77942e8..7aac95c6e53 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsUseCase.ts @@ -3,8 +3,8 @@ import { createImplementation } from "@webiny/feature/api"; import { ListGroupsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { ListGroupsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; import type { CmsGroup } from "~/types/index.js"; +import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; /** * ListGroupsUseCase - Retrieves all content model groups. @@ -24,7 +24,7 @@ class ListGroupsUseCaseImpl implements UseCaseAbstraction.Interface { // Initial access control check (no specific group yet) const canAccess = await this.accessControl.canAccessGroup(); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Repository uses GroupCache to fetch all groups diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts index 31979624b1f..82b0ee38199 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts @@ -1,8 +1,8 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; -import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; +import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; +import { type GroupStorageError } from "~/domain/contentModelGroup/errors.js"; /** * ListGroups Use Case @@ -12,7 +12,7 @@ export interface IListGroupsUseCase { } export interface IListGroupsUseCaseErrors { - notAuthorized: NotAuthorizedError; + notAuthorized: GroupNotAuthorizedError; repository: RepositoryError; } @@ -38,9 +38,8 @@ export interface IListGroupsRepositoryErrors { type RepositoryError = IListGroupsRepositoryErrors[keyof IListGroupsRepositoryErrors]; -export const ListGroupsRepository = createAbstraction( - "ListGroupsRepository" -); +export const ListGroupsRepository = + createAbstraction("ListGroupsRepository"); export namespace ListGroupsRepository { export type Interface = IListGroupsRepository; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts index 829c97bebd6..2ab111667e7 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts @@ -23,7 +23,7 @@ class UpdateGroupRepositoryImpl implements RepositoryAbstraction.Interface { private storageOperations: StorageOperations.Interface ) {} - async execute(group: CmsGroup): Promise> { + async execute(group: CmsGroup): Promise> { try { // Check if this is a plugin-based group (cannot be updated) const pluginGroups = await this.pluginGroupsProvider.getGroups(); @@ -34,12 +34,12 @@ class UpdateGroupRepositoryImpl implements RepositoryAbstraction.Interface { } // Persist updates - const updatedGroup = await this.storageOperations.groups.update({ group }); + await this.storageOperations.groups.update({ group }); // Clear cache this.groupCache.clear(); - return Result.ok(updatedGroup); + return Result.ok(); } catch (error) { return Result.fail(new GroupStorageError(error as Error)); } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts index 7ec15cab730..e763be0148e 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts @@ -9,7 +9,7 @@ import { GroupAfterUpdateEvent } from "./events.js"; import { GroupUpdateErrorEvent } from "./events.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import { GroupNotAuthorizedError, GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import { NotAuthorizedError } from "~/utils/errors.js"; import { createZodError } from "@webiny/utils"; import { createGroupUpdateValidation } from "~/domain/contentModelGroup/validation.js"; @@ -45,7 +45,7 @@ class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Initial access control check const canAccess = await this.accessControl.canAccessGroup({ rwd: "w" }); if (!canAccess) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Fetch original group @@ -58,7 +58,7 @@ class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { // Access control check on original group const canAccessGroup = await this.accessControl.canAccessGroup({ group: original }); if (!canAccessGroup) { - return Result.fail(new NotAuthorizedError()); + return Result.fail(new GroupNotAuthorizedError()); } // Validate input @@ -107,17 +107,15 @@ class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(result.error); } - const updatedGroup = result.value; - // Publish after event await this.eventPublisher.publish( new GroupAfterUpdateEvent({ original, - group: updatedGroup + group }) ); - return Result.ok(updatedGroup); + return Result.ok(group); } catch (error) { // Publish error event for unexpected errors await this.eventPublisher.publish( diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts index 14682e0200c..c512214db10 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts @@ -2,11 +2,10 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupUpdateInput } from "~/types/index.js"; -import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; import type { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; -import type { NotAuthorizedError } from "~/utils/errors.js"; /** * UpdateGroup Use Case @@ -17,7 +16,7 @@ export interface IUpdateGroupUseCase { export interface IUpdateGroupUseCaseErrors { notFound: GroupNotFoundError; - notAuthorized: NotAuthorizedError; + notAuthorized: GroupNotAuthorizedError; validation: GroupValidationError; repository: RepositoryError; } @@ -35,7 +34,7 @@ export namespace UpdateGroupUseCase { * UpdateGroupRepository - Persists group updates to storage. */ export interface IUpdateGroupRepository { - execute(group: CmsGroup): Promise>; + execute(group: CmsGroup): Promise>; } export interface IUpdateGroupRepositoryErrors { diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index fa52510a280..84f591e7876 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1703,15 +1703,15 @@ export interface CmsGroupStorageOperations { /** * Create a new content model group. */ - create: (params: CmsGroupStorageOperationsCreateParams) => Promise; + create: (params: CmsGroupStorageOperationsCreateParams) => Promise; /** * Update existing content model group. */ - update: (params: CmsGroupStorageOperationsUpdateParams) => Promise; + update: (params: CmsGroupStorageOperationsUpdateParams) => Promise; /** * Delete the content model group. */ - delete: (params: CmsGroupStorageOperationsDeleteParams) => Promise; + delete: (params: CmsGroupStorageOperationsDeleteParams) => Promise; } export interface CmsModelStorageOperationsGetParams { From d694ce229717a088fcafbc8d37232e23bc108afa Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 14 Nov 2025 18:20:14 +0100 Subject: [PATCH 17/71] wip: migrate content group model domain --- .../api-headless-cms-ddb/src/operations/group/index.ts | 3 --- .../features/contentEntry/GetUniqueFieldValues/errors.ts | 4 ++-- .../contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts | 3 +-- packages/api-headless-cms/src/utils/errors.ts | 9 --------- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/packages/api-headless-cms-ddb/src/operations/group/index.ts b/packages/api-headless-cms-ddb/src/operations/group/index.ts index a2e33d1411b..bc317a19499 100644 --- a/packages/api-headless-cms-ddb/src/operations/group/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/group/index.ts @@ -78,7 +78,6 @@ export const createGroupsStorageOperations = ( ...keys } }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not create group.", @@ -103,7 +102,6 @@ export const createGroupsStorageOperations = ( ...keys } }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not update group.", @@ -124,7 +122,6 @@ export const createGroupsStorageOperations = ( entity, keys }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not delete group.", diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts index 07a60d79c81..177c5dd7e2b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/errors.ts @@ -5,7 +5,7 @@ type FieldNotSearchableErrorData = { }; export class FieldNotSearchableError extends BaseError { - override readonly code = "FIELD_NOT_SEARCHABLE" as const; + override readonly code = "Cms/Entry/FieldNotSearchable" as const; constructor(fieldId: string) { super({ @@ -20,7 +20,7 @@ type InvalidWhereConditionErrorData = { }; export class InvalidWhereConditionError extends BaseError { - override readonly code = "INVALID_WHERE_CONDITION" as const; + override readonly code = "Cms/Entry/InvalidWhereCondition" as const; constructor(message: string, where: Record) { super({ diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts index e763be0148e..4ae9f3b2411 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts @@ -10,7 +10,6 @@ import { GroupUpdateErrorEvent } from "./events.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { GroupNotAuthorizedError, GroupValidationError } from "~/domain/contentModelGroup/errors.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; import { createZodError } from "@webiny/utils"; import { createGroupUpdateValidation } from "~/domain/contentModelGroup/validation.js"; import type { CmsGroup } from "~/types/index.js"; @@ -65,7 +64,7 @@ class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { const validationResult = await createGroupUpdateValidation().safeParseAsync(input); if (!validationResult.success) { const zodError = createZodError(validationResult.error); - return Result.fail(new GroupValidationError(zodError.message)); + return Result.fail(new GroupValidationError(zodError.message, zodError.data!.invalidFields)); } const data = validationResult.data; diff --git a/packages/api-headless-cms/src/utils/errors.ts b/packages/api-headless-cms/src/utils/errors.ts index a1400fb0c25..70c2be3a032 100644 --- a/packages/api-headless-cms/src/utils/errors.ts +++ b/packages/api-headless-cms/src/utils/errors.ts @@ -1,5 +1,4 @@ import { BaseError } from "@webiny/feature/api"; -import type { CmsEntry, CmsModel } from "~/types/index.js"; export class NotAuthorizedError extends BaseError { override readonly code = "NOT_AUTHORIZED" as const; @@ -9,12 +8,4 @@ export class NotAuthorizedError extends BaseError { message: message || "Not authorized!" }); } - - static fromModel(model: CmsModel): NotAuthorizedError { - return new NotAuthorizedError(`Not allowed to access "${model.modelId}" entries.`); - } - - static fromEntry(entry: CmsEntry): NotAuthorizedError { - return new NotAuthorizedError(`Not allowed to access entry "${entry.entryId}".`); - } } From dc23923da0d2e0deeafc315ae572d01fd3dcf648 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 14 Nov 2025 19:43:41 +0100 Subject: [PATCH 18/71] wip: migrate content model domain --- .../src/crud/contentEntry/afterDelete.ts | 41 ------ .../src/crud/contentEntry/beforeCreate.ts | 21 --- .../src/crud/contentEntry/beforeUpdate.ts | 21 --- .../src/crud/contentEntry/markLockedFields.ts | 129 ------------------ .../src/crud/contentModel.crud.ts | 20 --- .../src/domain/contentModel/errors.ts | 17 +++ 6 files changed, 17 insertions(+), 232 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/afterDelete.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/beforeCreate.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/beforeUpdate.ts delete mode 100644 packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts diff --git a/packages/api-headless-cms/src/crud/contentEntry/afterDelete.ts b/packages/api-headless-cms/src/crud/contentEntry/afterDelete.ts deleted file mode 100644 index 65f5a57b8c5..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/afterDelete.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * TODO: possibly remove this subscription. - * It creates problems when doing mass deletes on DDB only systems with a lot of entries. - */ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { CmsContext, OnEntryAfterDeleteTopicParams } from "~/types/index.js"; -import { markUnlockedFields } from "./markLockedFields.js"; - -interface AssignAfterEntryDeleteParams { - context: CmsContext; - onEntryAfterDelete: Topic; -} -export const assignAfterEntryDelete = (params: AssignAfterEntryDeleteParams) => { - const { context, onEntryAfterDelete } = params; - - onEntryAfterDelete.subscribe(async params => { - const { entry, model, permanent } = params; - if (model.isPlugin) { - return; - } - - // If the entry is being moved to the trash, we keep the model fields locked because the entry can be restored. - if (!permanent || !model.isPlugin) { - return; - } - const { items } = await context.cms.storageOperations.entries.list(model, { - where: { - entryId_not: entry.entryId, - latest: true - }, - limit: 1 - }); - if (items.length > 0) { - return; - } - await markUnlockedFields({ - context, - model - }); - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/beforeCreate.ts b/packages/api-headless-cms/src/crud/contentEntry/beforeCreate.ts deleted file mode 100644 index e5863288627..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/beforeCreate.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { OnEntryBeforeCreateTopicParams, CmsContext } from "~/types/index.js"; -import { markLockedFields } from "./markLockedFields.js"; - -interface AssignBeforeEntryCreateParams { - context: CmsContext; - onEntryBeforeCreate: Topic; -} -export const assignBeforeEntryCreate = (params: AssignBeforeEntryCreateParams) => { - const { context, onEntryBeforeCreate } = params; - - onEntryBeforeCreate.subscribe(async params => { - const { entry, model } = params; - - await markLockedFields({ - model, - entry, - context - }); - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/beforeUpdate.ts b/packages/api-headless-cms/src/crud/contentEntry/beforeUpdate.ts deleted file mode 100644 index 62ccfe5551f..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/beforeUpdate.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { OnEntryBeforeUpdateTopicParams, CmsContext } from "~/types/index.js"; -import { markLockedFields } from "./markLockedFields.js"; - -interface AssignBeforeEntryUpdateParams { - context: CmsContext; - onEntryBeforeUpdate: Topic; -} -export const assignBeforeEntryUpdate = (params: AssignBeforeEntryUpdateParams) => { - const { context, onEntryBeforeUpdate } = params; - - onEntryBeforeUpdate.subscribe(async params => { - const { entry, model } = params; - - await markLockedFields({ - model, - entry, - context - }); - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts b/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts deleted file mode 100644 index 89eeeeef53e..00000000000 --- a/packages/api-headless-cms/src/crud/contentEntry/markLockedFields.ts +++ /dev/null @@ -1,129 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { - CmsContext, - CmsEntry, - CmsModel, - CmsModelLockedFieldPlugin, - LockedField -} from "~/types/index.js"; -import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; -import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; - -interface MarkLockedFieldsParams { - model: CmsModel; - entry: CmsEntry; - context: CmsContext; -} - -export const markLockedFields = async (params: MarkLockedFieldsParams): Promise => { - const { model, context } = params; - /** - * If the model is registered via a plugin, we don't need to process anything. - */ - const plugins = context.plugins.byType(CmsModelPlugin.type); - if (plugins.find(plugin => plugin.contentModel.modelId === model.modelId)) { - return; - } - - const cmsLockedFieldPlugins = - context.plugins.byType("cms-model-locked-field"); - - const existingLockedFields = model.lockedFields || []; - const lockedFields: LockedField[] = []; - for (const field of model.fields) { - const baseType = getBaseFieldType(field); - const alreadyLocked = existingLockedFields.some( - lockedField => lockedField.fieldId === field.storageId - ); - if (alreadyLocked) { - continue; - } - - let lockedFieldData = {}; - - const lockedFieldPlugins = cmsLockedFieldPlugins.filter(pl => pl.fieldType === baseType); - for (const plugin of lockedFieldPlugins) { - if (typeof plugin.getLockedFieldData !== "function") { - continue; - } - const data = plugin.getLockedFieldData({ - field - }); - lockedFieldData = { ...lockedFieldData, ...data }; - } - - lockedFields.push({ - fieldId: field.storageId, - multipleValues: !!field.multipleValues, - type: baseType, - ...lockedFieldData - }); - } - // no need to update anything if no locked fields were added - if (lockedFields.length === 0) { - return; - } - - const newLockedFields = existingLockedFields.concat(lockedFields); - - try { - await context.cms.updateModelDirect({ - /** - * At this point we know this is a CmsModel, so it is safe to cast. - */ - original: model as CmsModel, - model: { - ...model, - lockedFields: newLockedFields - } as CmsModel - }); - model.lockedFields = newLockedFields; - } catch (ex) { - throw new WebinyError( - `Could not update model "${model.modelId}" with new locked fields.`, - "MODEL_LOCKED_FIELDS_UPDATE_FAILED", - { - message: ex.message, - code: ex.code, - data: ex.data - } - ); - } -}; - -export interface MarkFieldsUnlockedParams { - context: CmsContext; - model: CmsModel; -} - -export const markUnlockedFields = async (params: MarkFieldsUnlockedParams) => { - const { context, model } = params; - /** - * If the model is registered via a plugin, we don't need do process anything. - */ - const plugins = context.plugins.byType(CmsModelPlugin.type); - if (plugins.find(plugin => plugin.contentModel.modelId === model.modelId)) { - return; - } - - try { - await context.cms.updateModelDirect({ - original: model as CmsModel, - model: { - ...model, - lockedFields: [] - } - }); - model.lockedFields = []; - } catch (ex) { - throw new WebinyError( - `Could not update model "${model.modelId}" with unlocked fields.`, - "MODEL_UNLOCKED_FIELDS_UPDATE_FAILED", - { - message: ex.message, - code: ex.code, - data: ex.data - } - ); - } -}; diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 2ea9215f716..0e3d2a9e9b8 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -27,9 +27,6 @@ import type { import { NotFoundError } from "@webiny/handler-graphql"; import { contentModelManagerFactory } from "./contentModel/contentModelManagerFactory.js"; import { createTopic } from "@webiny/pubsub"; -import { assignModelBeforeCreate } from "./contentModel/beforeCreate.js"; -import { assignModelBeforeUpdate } from "./contentModel/beforeUpdate.js"; -import { assignModelBeforeDelete } from "./contentModel/beforeDelete.js"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; import { createModelCreateFromValidation, @@ -282,23 +279,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex * Initialize */ const onModelInitialize = createTopic("cms.onModelInitialize"); - /** - * We need to assign some default behaviors. - */ - assignModelBeforeCreate({ - onModelBeforeCreate, - onModelBeforeCreateFrom, - context, - storageOperations - }); - assignModelBeforeUpdate({ - onModelBeforeUpdate, - context - }); - assignModelBeforeDelete({ - onModelBeforeDelete, - context - }); /** * CRUD methods diff --git a/packages/api-headless-cms/src/domain/contentModel/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts index 111956623c9..47e4d51e6c7 100644 --- a/packages/api-headless-cms/src/domain/contentModel/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModel/errors.ts @@ -1,4 +1,21 @@ import { BaseError } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; + +export class ModelNotAuthorizedError extends BaseError { + override readonly code = "Cms/Model/NotAuthorized" as const; + + constructor(message?: string) { + super({ + message: message || `Not allowed to access content models.` + }); + } + + static fromModel(model: CmsModel): ModelNotAuthorizedError { + return new ModelNotAuthorizedError( + `Not allowed to access content model "${model.modelId}".` + ); + } +} export class ModelNotFoundError extends BaseError { override readonly code = "Cms/Model/NotFound" as const; From 9802b8301a3c75f56375269c3d3c7fa47152a2c9 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 14 Nov 2025 21:12:21 +0100 Subject: [PATCH 19/71] wip: update di container dep and context docs --- ai-context/backend-developer-guide.md | 11 +- ai-context/cms-models.md | 1362 ----------------- ai-context/di-container.md | 47 +- ai-context/event-publisher.md | 32 +- ai-context/simple-models.md | 16 +- package.json | 2 +- packages/api-aco/package.json | 2 +- packages/api-core/package.json | 2 +- .../CreateApiKey/CreateApiKeyUseCase.ts | 4 +- packages/api-headless-cms/package.json | 2 +- packages/api/package.json | 2 +- packages/app-admin/package.json | 2 +- packages/app/package.json | 2 +- packages/cli-core/package.json | 2 +- packages/feature/package.json | 2 +- packages/project-aws/package.json | 2 +- packages/project/package.json | 2 +- yarn.lock | 30 +- 18 files changed, 92 insertions(+), 1432 deletions(-) delete mode 100644 ai-context/cms-models.md diff --git a/ai-context/backend-developer-guide.md b/ai-context/backend-developer-guide.md index cd9ef54f724..fc3db741ab7 100644 --- a/ai-context/backend-developer-guide.md +++ b/ai-context/backend-developer-guide.md @@ -107,7 +107,7 @@ export namespace UpdateUserUseCase { } ``` -You MUST ALWAYS use `createAbstraction` instead of `createAbstraction`. +You MUST ALWAYS use `createAbstraction` instead of `new Abstraction`. **Use case implementation** @@ -115,11 +115,10 @@ You MUST ALWAYS use `createAbstraction` instead of `createAbstraction`. 1. Use case class MUST implement the abstraction's `.Interface` type 2. Use case method return types MUST use the abstraction's `.Error` namespace type 3. Constructor parameters MUST use `.Interface` types from their abstractions -4. Always use `createImplementation` to wire up the use case +4. Always use `Abstraction.createImplementation` to wire up the use case ```typescript // UpdateUserUseCase.ts -import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; // Import abstraction you're implementing @@ -156,8 +155,7 @@ export class UpdateUserUseCase implements UseCaseAbstraction.Interface { } // Wire up with createImplementation -export const UpdateUserUseCaseImpl = createImplementation({ - abstraction: UseCaseAbstraction, +export const UpdateUserUseCaseImpl = UseCaseAbstraction.createImplementation({ implementation: UpdateUserUseCase, dependencies: [SupportedLanguagesProvider, UserRepository] }); @@ -197,8 +195,7 @@ class CreatePageValidationDecoratorImpl { } } -export const CreatePageValidationDecorator = createDecorator({ - abstraction: CreatePageUseCase, +export const CreatePageValidationDecorator = CreatePageUseCase.createDecorator({ decorator: CreatePageValidationDecoratorImpl, dependencies: [] }); diff --git a/ai-context/cms-models.md b/ai-context/cms-models.md deleted file mode 100644 index 942317a539c..00000000000 --- a/ai-context/cms-models.md +++ /dev/null @@ -1,1362 +0,0 @@ -```ts -// FieldBuilder.ts -import { z } from "zod"; -import type { FieldConfig, ValidationRule } from "./types.js"; -import type { FieldBuilderRegistry } from "./FieldBuilderRegistry.js"; - -// Make FieldBuilder return the Zod type it produces -export abstract class FieldBuilder { - protected config: Partial = { - validation: [] - }; - protected zodSchema: TZod; - - constructor( - type: string, - initialSchema: TZod - ) { - this.config.type = type; - this.zodSchema = initialSchema; - } - - label(text: string): this { - this.config.label = text; - return this; - } - - helpText(text: string): this { - this.config.helpText = text; - return this; - } - - placeholder(text: string): this { - this.config.placeholderText = text; - return this; - } - - toConfig(): FieldConfig { - return { - fieldId: '', - type: this.config.type!, - label: this.config.label, - helpText: this.config.helpText, - placeholderText: this.config.placeholderText, - validation: this.config.validation || [], - settings: this.config.settings || {}, - zodSchema: this.zodSchema - }; - } - - getZodSchema(): TZod { - return this.zodSchema; - } -} - -// Text Field Builder - properly track type transformations -export class TextFieldBuilder extends FieldBuilder { - constructor(schema?: TZod) { - super('text', (schema || z.string()) as TZod); - } - - required(message?: string): TextFieldBuilder { - const newSchema = (this.zodSchema as z.ZodString).min(1, message || 'Field is required'); - this.zodSchema = newSchema as any; - this.config.validation?.push({ - name: 'required', - message: message || 'Field is required', - settings: {} - }); - return this as any; - } - - minLength(length: number, message?: string): this { - this.zodSchema = (this.zodSchema as z.ZodString).min(length, message) as TZod; - return this; - } - - maxLength(length: number, message?: string): this { - this.zodSchema = (this.zodSchema as z.ZodString).max(length, message) as TZod; - return this; - } - - slug(): this { - this.zodSchema = (this.zodSchema as z.ZodString).regex( - /^[a-z0-9-]+$/, - 'Must be a valid slug (lowercase letters, numbers, and hyphens only)' - ) as TZod; - this.config.validation?.push({ - name: 'slug', - message: 'Must be a valid slug', - settings: {} - }); - return this; - } - - email(): this { - this.zodSchema = (this.zodSchema as z.ZodString).email('Must be a valid email') as TZod; - this.config.validation?.push({ - name: 'email', - message: 'Must be a valid email', - settings: {} - }); - return this; - } - - url(): this { - this.zodSchema = (this.zodSchema as z.ZodString).url('Must be a valid URL') as TZod; - this.config.validation?.push({ - name: 'url', - message: 'Must be a valid URL', - settings: {} - }); - return this; - } - - unique(): this { - this.config.validation?.push({ - name: 'unique', - message: 'Value must be unique', - settings: {} - }); - return this; - } -} - -// Object Field Builder - track the shape type -export class ObjectFieldBuilder extends FieldBuilder> { - private nestedFields: FieldConfig[] = []; - - constructor( - fields: (registry: FieldBuilderRegistry) => { [K in keyof TShape]: FieldBuilder }, - registry: FieldBuilderRegistry - ) { - const fieldBuilders = fields(registry); - const shape = {} as TShape; - const nestedConfigs: FieldConfig[] = []; - - for (const [fieldId, builder] of Object.entries(fieldBuilders) as Array<[keyof TShape, FieldBuilder]>) { - const config = builder.toConfig(); - config.fieldId = fieldId as string; - nestedConfigs.push(config); - shape[fieldId] = builder.getZodSchema() as TShape[keyof TShape]; - } - - super('object', z.object(shape)); - this.nestedFields = nestedConfigs; - this.config.fields = nestedConfigs; - } - - optional(): ObjectFieldBuilder & { getZodSchema(): z.ZodOptional> } { - this.zodSchema = this.zodSchema.optional() as any; - return this as any; - } - - nullable(): ObjectFieldBuilder & { getZodSchema(): z.ZodNullable> } { - this.zodSchema = this.zodSchema.nullable() as any; - return this as any; - } - - override toConfig(): FieldConfig { - const config = super.toConfig(); - config.fields = this.nestedFields; - return config; - } -} -``` - -```ts -// FieldBuilderRegistry.ts -import { z } from "zod"; -import { createImplementation } from "@webiny/di"; -import { TextFieldBuilder, ObjectFieldBuilder, type FieldBuilder } from "./FieldBuilder.js"; -import { - FieldBuilderRegistry as RegistryAbstraction, - type IFieldBuilderRegistry -} from "./abstractions.js"; - -class FieldBuilderRegistryImpl implements IFieldBuilderRegistry { - text(): TextFieldBuilder { - return new TextFieldBuilder(); - } - - object( - fields: (registry: IFieldBuilderRegistry) => { - [K in keyof TShape]: FieldBuilder; - } - ): ObjectFieldBuilder { - return new ObjectFieldBuilder(fields, this); - } -} - -export const FieldBuilderRegistry = createImplementation({ - abstraction: RegistryAbstraction, - implementation: FieldBuilderRegistryImpl, - dependencies: [] -}); -``` - -```ts -// FieldDefinitionsBuilder.ts -import { z } from "zod"; -import type { FieldBuilder } from "./FieldBuilder.js"; -import type { IFieldBuilderRegistry } from "./abstractions.js"; -import type { FieldConfig, FieldBuilderConfig } from "./types.js"; - -export class FieldDefinitionsBuilder { - private fields = new Map(); - - constructor(private registry: IFieldBuilderRegistry) {} - - // Keep the old .field() method for internal use - field( - name: K, - configure: (field: IFieldBuilderRegistry) => FieldBuilder - ): FieldDefinitionsBuilder> { - const fieldBuilder = configure(this.registry); - const config = fieldBuilder.toConfig(); - config.fieldId = name; - - this.fields.set(name, { - fieldId: name, - config, - zodSchema: fieldBuilder.getZodSchema() - }); - - return this as any; - } - - // Internal method to build from object - __fromObject>>( - shape: TShape - ): FieldDefinitionsBuilder<{ [K in keyof TShape]: ReturnType }> { - for (const [fieldId, fieldBuilder] of Object.entries(shape)) { - const config = fieldBuilder.toConfig(); - config.fieldId = fieldId; - - this.fields.set(fieldId, { - fieldId, - config, - zodSchema: fieldBuilder.getZodSchema() - }); - } - - return this as any; - } - - __toZodSchema(): z.ZodObject { - const schemaShape = {} as TFields; - - for (const [fieldId, { zodSchema }] of this.fields) { - schemaShape[fieldId as keyof TFields] = zodSchema as TFields[keyof TFields]; - } - - return z.object(schemaShape); - } - - __getFields(): FieldConfig[] { - return Array.from(this.fields.values()).map(f => f.config); - } - - __getFieldsMap(): Map { - return new Map(this.fields); - } -} - -// Updated factory function - now takes an object! -export function createFieldDefinitions>>( - factory: (fields: IFieldBuilderRegistry) => TShape -): FieldDefinitionsFactory<{ [K in keyof TShape]: ReturnType }> { - return { - __type: "FieldDefinitionsFactory" as const, - factory - }; -} - -export interface FieldDefinitionsFactory { - __type: "FieldDefinitionsFactory"; - factory: (fields: IFieldBuilderRegistry) => Record>; -} - -export type InferFieldSchema = - T extends FieldDefinitionsFactory ? z.ZodObject : never; -``` - -```ts -// PrivateCmsModelBuilder.ts -import { z } from "zod"; -import { - FieldDefinitionsBuilder, - type FieldDefinitionsFactory -} from "./FieldDefinitionsBuilder.js"; -import type { BaseModel } from "~/models/BaseModel.js"; -import type { IModelData } from "~/models/abstractions.js"; -import type { PrivateCmsModel, CmsModelMetadata, FieldBuilderConfig } from "./types.js"; -// Import your existing ModelBuilder -import { ModelBuilder } from "~/models/ModelBuilder.js"; -import { FieldBuilder } from "./FieldBuilder.js"; -import { createImplementation } from "@webiny/di"; -import { - FieldBuilderRegistry, - type IFieldBuilderRegistry, - PrivateCmsModelBuilder as BuilderAbstraction -} from "~/cms/abstractions.js"; - -export interface IPrivateCmsModelBuilder { - create, TFields extends z.ZodRawShape = any>( - modelId: string, - fieldDefinitions: FieldDefinitionsFactory - ): IPrivateCmsModelConfiguration; -} - -export interface IPrivateCmsModelConfiguration> { - withMethods( - methods: TMethods & ThisType - ): IPrivateCmsModelConfiguration; - extendFields( - factory: (fields: IFieldBuilderRegistry) => Record> - ): IPrivateCmsModelConfiguration; - build(): PrivateCmsModel; -} - -class PrivateCmsModelBuilderImpl implements IPrivateCmsModelBuilder { - constructor(private fieldBuilderRegistry: IFieldBuilderRegistry) {} - - create, TFields extends z.ZodRawShape = any>( - modelId: string, - fieldDefinitions: FieldDefinitionsFactory - ): IPrivateCmsModelConfiguration { - const builder = new FieldDefinitionsBuilder(this.fieldBuilderRegistry); - const fieldShape = fieldDefinitions.factory(this.fieldBuilderRegistry); - const fieldDefinitionsBuilder = builder.__fromObject(fieldShape); - - return new PrivateCmsModelConfiguration( - modelId, - this.fieldBuilderRegistry, - fieldDefinitionsBuilder - ); - } -} - -class PrivateCmsModelConfiguration> - implements IPrivateCmsModelConfiguration -{ - private metadata: CmsModelMetadata = {}; - private modelMethods: Record = {}; - private extensionFields = new Map(); - - constructor( - private modelId: string, - private fieldBuilderRegistry: IFieldBuilderRegistry, - private fieldDefinitionsBuilder: FieldDefinitionsBuilder - ) {} - - withMethods( - methods: TMethods & ThisType - ): IPrivateCmsModelConfiguration { - Object.assign(this.modelMethods, methods); - return this as any; - } - - extendFields( - factory: (fields: IFieldBuilderRegistry) => Record> - ): IPrivateCmsModelConfiguration { - // Use the factory to get field shape - same as createFieldDefinitions! - const fieldShape = factory(this.fieldBuilderRegistry); - - // Convert to field configs - for (const [fieldId, fieldBuilder] of Object.entries(fieldShape)) { - const config = fieldBuilder.toConfig(); - config.fieldId = fieldId; - const zodSchema = fieldBuilder.getZodSchema(); - - this.extensionFields.set(fieldId, { - fieldId, - config, - zodSchema - }); - } - - return this as any; - } - - build(): PrivateCmsModel { - const baseSchema = this.fieldDefinitionsBuilder.__toZodSchema(); - let finalSchema = baseSchema; - - if (this.extensionFields.size > 0) { - const extensionsShape: z.ZodRawShape = {}; - - for (const [fieldId, { zodSchema }] of this.extensionFields) { - // Make each extension field optional - extensionsShape[fieldId] = zodSchema.optional(); - } - - const extensionsSchema = z.object(extensionsShape).optional(); - - finalSchema = baseSchema.extend({ - extensions: extensionsSchema - }) as any; - } - - let modelBuilder = new ModelBuilder(this.modelId, finalSchema); - - if (Object.keys(this.modelMethods).length > 0) { - modelBuilder = modelBuilder.withMethods(this.modelMethods); - } - - const ModelClass = modelBuilder.build(); - - const allFields = [ - ...this.fieldDefinitionsBuilder.__getFields(), - ...(this.extensionFields.size > 0 - ? [ - { - fieldId: "extensions", - type: "object", - label: "Extensions", - validation: [], - settings: {}, - zodSchema: z - .object( - Object.fromEntries( - Array.from(this.extensionFields.entries()).map( - ([id, { zodSchema }]) => [id, zodSchema.optional()] - ) - ) - ) - .optional(), - fields: Array.from(this.extensionFields.values()).map(f => f.config) - } - ] - : []) - ]; - - return { - type: "private" as const, - modelType: "private" as const, - Model: ModelClass as any, - modelId: this.modelId, - name: this.modelId, - icon: this.metadata.icon, - description: this.metadata.description, - fields: allFields, - schema: modelBuilder.getSchema() as TModel["__schema"], - create: (data: IModelData) => ModelClass.create(data) as TModel - }; - } -} - -export const PrivateCmsModelBuilder = createImplementation({ - abstraction: BuilderAbstraction, - implementation: PrivateCmsModelBuilderImpl, - dependencies: [FieldBuilderRegistry] -}); -``` - -```ts -// PrivatePage/Page.fields.ts -import { createFieldDefinitions, type InferFieldSchema } from "~/cms/FieldDefinitionsBuilder.js"; - -export const PageFieldDefinitions = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().label("Title").required(), - path: fields.text().label("Path").required(), - content: fields.text().label("Content") -})); - -export type PageFieldsSchema = InferFieldSchema; -``` - -```ts -// PrivatePage/PageModelBuilder.ts -import { createImplementation } from "@webiny/di"; -import { PageFieldDefinitions } from "./Page.fields.js"; -import { PrivateCmsModelBuilder } from "~/cms/abstractions.js"; -import { - IPage, - PageCmsModelBuilder as BuilderAbstraction -} from "~/cms/PrivatePage/abstractions.js"; - -class PageCmsModelBuilderImpl implements BuilderAbstraction.Interface { - constructor(private privateCmsModelBuilder: PrivateCmsModelBuilder.Interface) {} - - async buildCmsModel() { - return this.privateCmsModelBuilder.create("page", PageFieldDefinitions).withMethods({ - getFullPath() { - return this.path.startsWith("/") ? this.path : `/${this.path}`; - } - }); - } -} - -export const PageCmsModelBuilder = createImplementation({ - abstraction: BuilderAbstraction, - implementation: PageCmsModelBuilderImpl, - dependencies: [PrivateCmsModelBuilder] -}); -``` - -```ts -// PrivatePage/PageModelFactory.ts -import { createImplementation } from "@webiny/di"; -import { - PageModelFactory as FactoryAbstraction, - PageCmsModelBuilder, - type IPage -} from "./abstractions.js"; -import type { ModelClass } from "~/models/ModelBuilder.js"; -import type { PrivateCmsModel } from "~/cms/types.js"; - -class PageModelFactoryImpl implements FactoryAbstraction.Interface { - private modelClass: ModelClass | undefined; - private cmsModel: PrivateCmsModel | undefined; - - constructor(private cmsModelBuilder: PageCmsModelBuilder.Interface) {} - - async create(data: FactoryAbstraction.CreateInput): Promise { - if (this.modelClass) { - return this.modelClass.create(data); - } - - // Build the CMS model configuration - const builder = await this.cmsModelBuilder.buildCmsModel(); - - // Build the final model - this.cmsModel = builder.build(); - this.modelClass = this.cmsModel.Model; - - return this.modelClass.create(data); - } - - // Optional: expose the CMS model metadata - getCmsModel(): PrivateCmsModel | undefined { - return this.cmsModel; - } -} - -export const PageModelFactory = createImplementation({ - abstraction: FactoryAbstraction, - implementation: PageModelFactoryImpl, - dependencies: [PageCmsModelBuilder] -}); -``` - -```ts -// PrivatePage/__tests__/PageModelFactory.test.ts -import { describe, it, expect } from "vitest"; -import { Container } from "@webiny/di"; -import { FieldBuilderRegistry } from "~/cms/FieldBuilderRegistry.js"; -import { PrivateCmsModelBuilder } from "~/cms/PrivateCmsModelBuilder.js"; -import { PageCmsModelBuilder } from "../PageModelBuilder.js"; -import { PageModelFactory } from "../PageModelFactory.js"; -import { PageModelFactory as FactoryAbstraction } from "../abstractions.js"; -import { PageSeoDecorator } from "./PageSeoDecorator.js"; -import { PagePublishingDecorator } from "./PagePublishingDecorator.js"; - -describe("PageModelFactory", () => { - const createContainer = () => { - const container = new Container(); - container.register(FieldBuilderRegistry); - container.register(PrivateCmsModelBuilder); - container.register(PageCmsModelBuilder); - container.register(PageModelFactory); - return container; - }; - - it("should create page instances", async () => { - const container = createContainer(); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home Page", - path: "home", - content: "Welcome to our site" - }); - - expect(page.id).toBe("1"); - expect(page.title).toBe("Home Page"); - expect(page.path).toBe("home"); - expect(page.getFullPath()).toBe("/home"); - }); - - it("should work with path that already has slash", async () => { - const container = new Container(); - container.register(FieldBuilderRegistry); - container.register(PrivateCmsModelBuilder); - container.register(PageCmsModelBuilder); - container.register(PageModelFactory); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "About", - path: "/about", - content: "About us" - }); - - expect(page.getFullPath()).toBe("/about"); - }); - - it("should cache model class and reuse it", async () => { - const container = createContainer(); - - const factory = container.resolve(FactoryAbstraction); - - const page1 = await factory.create({ - id: "1", - title: "Page 1", - path: "page-1", - content: "Content 1" - }); - - const page2 = await factory.create({ - id: "2", - title: "Page 2", - path: "page-2", - content: "Content 2" - }); - - // Both should be instances of the same class - expect(page1.constructor).toBe(page2.constructor); - }); - - describe("With SEO Decorator", () => { - it("should add SEO fields and methods", async () => { - const container = createContainer(); - - // Register SEO decorator - container.registerDecorator(PageSeoDecorator); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home", - path: "home", - content: "Content", - extensions: { - seo: { - title: "Home - SEO Title", - description: "SEO Description", - keywords: "home, website" - } - } - }); - - expect(page.getSeoTitle()).toBe("Home - SEO Title"); - expect(page.hasSeo()).toBe(true); - }); - - it("should fall back to title when no SEO title", async () => { - const container = createContainer(); - - container.registerDecorator(PageSeoDecorator); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home", - path: "home", - content: "Content" - }); - - expect(page.getSeoTitle()).toBe("Home"); - expect(page.hasSeo()).toBe(false); - }); - }); - - describe("With Publishing Decorator", () => { - it("should add publishing fields and methods", async () => { - const container = createContainer(); - - container.registerDecorator(PagePublishingDecorator); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home", - path: "home", - content: "Content" - }); - - expect(page.isDraft()).toBe(true); - expect(page.isPublished()).toBe(false); - - page.publish("user-123"); - - expect(page.isDraft()).toBe(false); - expect(page.isPublished()).toBe(true); - expect(page.extensions?.publishedBy).toBe("user-123"); - expect(page.extensions?.publishedAt).toBeDefined(); - }); - - it("should unpublish page", async () => { - const container = createContainer(); - - container.registerDecorator(PagePublishingDecorator); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home", - path: "home", - content: "Content", - extensions: { - status: "published", - publishedAt: "2024-01-01", - publishedBy: "user-123" - } - }); - - expect(page.isPublished()).toBe(true); - - page.unpublish(); - - expect(page.isDraft()).toBe(true); - expect(page.isPublished()).toBe(false); - }); - }); - - describe("With Multiple Decorators", () => { - it("should combine SEO and Publishing decorators", async () => { - const container = createContainer(); - - // Register both decorators - container.registerDecorator(PageSeoDecorator); - container.registerDecorator(PagePublishingDecorator); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Home", - path: "home", - content: "Content", - extensions: { - seo: { - title: "Home SEO", - description: "Description", - keywords: "keywords" - } - } - }); - - // Base methods work - expect(page.getFullPath()).toBe("/home"); - - // SEO methods work - expect(page.getSeoTitle()).toBe("Home SEO"); - expect(page.hasSeo()).toBe(true); - - // Publishing methods work - expect(page.isDraft()).toBe(true); - page.publish("user-456"); - expect(page.isPublished()).toBe(true); - expect(page.extensions?.publishedBy).toBe("user-456"); - }); - }); - - describe("Model Operations", () => { - it("should support clone()", async () => { - const container = createContainer(); - - const factory = container.resolve(FactoryAbstraction); - - const original = await factory.create({ - id: "1", - title: "Original", - path: "original", - content: "Content" - }); - - const cloned = original.clone(); - - expect(cloned).not.toBe(original); - expect(cloned.id).toBe(original.id); - expect(cloned.title).toBe(original.title); - - cloned.title = "Modified"; - expect(original.title).toBe("Original"); - expect(cloned.title).toBe("Modified"); - }); - - it("should support updateWith()", async () => { - const container = createContainer(); - - const factory = container.resolve(FactoryAbstraction); - - const page = await factory.create({ - id: "1", - title: "Original", - path: "original", - content: "Content" - }); - - page.updateWith({ - title: "Updated", - content: "New Content" - }); - - expect(page.id).toBe("1"); - expect(page.title).toBe("Updated"); - expect(page.path).toBe("original"); - expect(page.content).toBe("New Content"); - }); - }); -}); -``` - -```ts -// PrivatePage/__tests__/PagePublishingDecorator.ts -import { createDecorator } from "@webiny/di"; -import { PageCmsModelBuilder } from "~/cms/PrivatePage/abstractions.js"; - -class PagePublishingDecoratorImpl implements PageCmsModelBuilder.Interface { - constructor(private decoratee: PageCmsModelBuilder.Interface) {} - - async buildCmsModel() { - const builder = await this.decoratee.buildCmsModel(); - - return builder - .extendFields(fields => ({ - publishedAt: fields.text().label("Published At"), - publishedBy: fields.text().label("Published By"), - status: fields.text().label("Status") - })) - .withMethods({ - publish(userId: string) { - if (!this.extensions) { - this.extensions = {}; - } - this.extensions.publishedAt = new Date().toISOString(); - this.extensions.publishedBy = userId; - this.extensions.status = "published"; - }, - unpublish() { - if (!this.extensions) { - this.extensions = {}; - } - this.extensions.status = "draft"; - }, - isPublished() { - return this.extensions?.status === "published"; - }, - isDraft() { - return !this.extensions?.status || this.extensions.status === "draft"; - } - }); - } -} - -export const PagePublishingDecorator = createDecorator({ - abstraction: PageCmsModelBuilder, - decorator: PagePublishingDecoratorImpl, - dependencies: [] -}); - -declare module "~/cms/PrivatePage/abstractions.js" { - interface IPage { - publish(userId: string): void; - unpublish(): void; - isPublished(): boolean; - isDraft(): boolean; - } - - interface IPageExtensions { - publishedAt?: string; - publishedBy?: string; - status?: "draft" | "published" | "archived"; - } -} -``` - -```ts -// PrivatePage/__tests__/PageSeoDecorator.ts -import { createDecorator } from "@webiny/di"; -import { PageCmsModelBuilder } from "~/cms/PrivatePage/abstractions.js"; - -class PageSeoDecoratorImpl implements PageCmsModelBuilder.Interface { - constructor(private decoratee: PageCmsModelBuilder.Interface) {} - - async buildCmsModel() { - const builder = await this.decoratee.buildCmsModel(); - - return builder - .extendFields(fields => ({ - seo: fields - .object(reg => ({ - title: reg.text().label("SEO Title"), - description: reg.text().label("SEO Description"), - keywords: reg.text().label("Keywords") - })) - .label("SEO Settings") - })) - .withMethods({ - getSeoTitle() { - return this.extensions?.seo?.title || this.title; - }, - hasSeo() { - return !!this.extensions?.seo?.title; - } - }); - } -} - -export const PageSeoDecorator = createDecorator({ - abstraction: PageCmsModelBuilder, - decorator: PageSeoDecoratorImpl, - dependencies: [] -}); - -// Module augmentation -declare module "~/cms/PrivatePage/abstractions.js" { - interface IPage { - getSeoTitle(): string; - hasSeo(): boolean; - } - - interface IPageExtensions { - seo?: { - title: string; - description: string; - keywords: string; - }; - } -} -``` - -```ts -// PrivatePage/abstractions.ts -import { createAbstraction } from "@webiny/features/api"; -import type { IModel, IModelData } from "~/models/abstractions.js"; -import type { ICmsModelBuilder, ICmsModelFactory } from "~/cms/abstractions.js"; -import type { PageFieldsSchema } from "./Page.fields.js"; - -// Extension interface for plugins to augment -export interface IPageExtensions {} - -// Page model interface -export interface IPage extends IModel { - getFullPath(): string; - extensions?: IPageExtensions; -} - -// Page-specific model builder abstraction -export const PageCmsModelBuilder = createAbstraction>("PageCmsModelBuilder"); - -export namespace PageCmsModelBuilder { - export type Interface = ICmsModelBuilder; -} - -// Page-specific model factory abstraction -export const PageModelFactory = createAbstraction>("PageModelFactory"); - -export namespace PageModelFactory { - export type Interface = ICmsModelFactory; - export type CreateInput = IModelData; -} -``` - -```ts -// __tests__/PrivateCmsModelBuilder.test.ts -import { describe, it, expect } from "vitest"; -import { PrivateCmsModelBuilder } from "../PrivateCmsModelBuilder.js"; -import { FieldBuilderRegistry } from "../FieldBuilderRegistry.js"; -import { createFieldDefinitions, type InferFieldSchema } from "../FieldDefinitionsBuilder.js"; -import type { IModel } from "~/models/abstractions.js"; - -describe("PrivateCmsModelBuilder", () => { - const registry = new FieldBuilderRegistry(); - const builder = new PrivateCmsModelBuilder(registry); - - describe("Basic Model Creation", () => { - it("should create a basic model with text fields", () => { - // ✅ Object syntax! - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().label("Title").required() - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel {} - - const model = builder.create("test", fieldDefs).build(); - - expect(model.modelId).toBe("test"); - expect(model.fields).toHaveLength(2); - expect(model.fields[0].fieldId).toBe("id"); - expect(model.fields[1].fieldId).toBe("title"); - }); - - it("should properly infer types", () => { - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - name: fields.text().label("Name").required(), - email: fields.text().email().required() - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel {} - - const model = builder.create("test", fieldDefs).build(); - - // ✅ Fully typed! - const instance = model.create({ - id: "1", - name: "John Doe", - email: "john@example.com" - }); - - expect(instance.id).toBe("1"); - expect(instance.name).toBe("John Doe"); - expect(instance.email).toBe("john@example.com"); - }); - }); - - describe("Object Fields", () => { - it("should support nested object fields", () => { - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - author: fields - .object(reg => ({ - name: reg.text().required(), - email: reg.text().email().required() - })) - .label("Author") - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel {} - - const model = builder.create("test", fieldDefs).build(); - - const instance = model.create({ - id: "1", - author: { - name: "John Doe", - email: "john@example.com" - } - }); - - expect(instance.author.name).toBe("John Doe"); - expect(instance.author.email).toBe("john@example.com"); - }); - }); - - describe("Field Extensions", () => { - it("should add extension fields under extensions property", () => { - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().required() - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel { - extensions?: { - seo?: { - title: string; - description: string; - }; - }; - } - - // ✅ Same object syntax as createFieldDefinitions! - const model = builder - .create("test", fieldDefs) - .extendFields(fields => ({ - seo: fields - .object(reg => ({ - title: reg.text().label("SEO Title"), - description: reg.text().label("SEO Description") - })) - .label("SEO Settings") - })) - .build(); - - expect(model.fields).toHaveLength(3); - }); - - it("should support multiple extension fields", () => { - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().required() - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel { - extensions?: { - seo?: { title: string }; - published?: string; - }; - } - - // ✅ Clean object syntax! - const model = builder - .create("test", fieldDefs) - .extendFields(fields => ({ - seo: fields.object(reg => ({ - title: reg.text() - })), - published: fields.text() - })) - .build(); - - const instance = model.create({ - id: "1", - title: "Test", - extensions: { - seo: { title: "SEO" }, - published: "2024-01-01" - } - }); - - expect(instance.extensions?.seo?.title).toBe("SEO"); - expect(instance.extensions?.published).toBe("2024-01-01"); - }); - - it("should chain multiple extendFields calls", () => { - const fieldDefs = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().required() - })); - - type Schema = InferFieldSchema; - interface ITestModel extends IModel { - extensions?: { - seo?: { title: string }; - analytics?: { tracking: string }; - }; - } - - // ✅ Can chain multiple calls! - const model = builder - .create("test", fieldDefs) - .extendFields(fields => ({ - seo: fields.object(reg => ({ - title: reg.text() - })) - })) - .extendFields(fields => ({ - analytics: fields.object(reg => ({ - tracking: reg.text() - })) - })) - .build(); - - const instance = model.create({ - id: "1", - title: "Test", - extensions: { - seo: { title: "SEO" }, - analytics: { tracking: "GA-123" } - } - }); - - expect(instance.extensions?.seo?.title).toBe("SEO"); - expect(instance.extensions?.analytics?.tracking).toBe("GA-123"); - }); - }); - - describe("Decorator Pattern", () => { - it("should work with decorator pattern", () => { - const PageFieldDefinitions = createFieldDefinitions(fields => ({ - id: fields.text().required(), - title: fields.text().label("Title").required(), - path: fields.text().label("Path").slug().required() - })); - - type PageFieldsSchema = InferFieldSchema; - - interface IPageExtensions { - seo?: { title: string; description: string }; - publishedAt?: string; - publishedBy?: string; - } - - interface IPage extends IModel { - getFullPath(): string; - getSeoTitle(): string; - publish(userId: string): void; - isPublished(): boolean; - extensions?: IPageExtensions; - } - - const baseBuilder = builder.create("page", PageFieldDefinitions).withMethods({ - getFullPath() { - return this.path.startsWith("/") ? this.path : `/${this.path}`; - } - }); - - // ✅ Clean object syntax in decorators! - const withSeo = baseBuilder - .extendFields(fields => ({ - seo: fields.object(reg => ({ - title: reg.text(), - description: reg.text() - })) - })) - .withMethods({ - getSeoTitle() { - return this.extensions?.seo?.title || this.title; - } - }); - - // ✅ Clean object syntax for publishing! - const withPublishing = withSeo - .extendFields(fields => ({ - publishedAt: fields.text(), - publishedBy: fields.text() - })) - .withMethods({ - publish(userId: string) { - if (!this.extensions) { - this.extensions = {}; - } - this.extensions.publishedAt = new Date().toISOString(); - this.extensions.publishedBy = userId; - }, - isPublished() { - return !!this.extensions?.publishedAt; - } - }); - - const model = withPublishing.build(); - - const page = model.create({ - id: "1", - title: "Home", - path: "home", - extensions: { - seo: { title: "Home SEO", description: "Home Description" } - } - }); - - expect(page.getFullPath()).toBe("/home"); - expect(page.getSeoTitle()).toBe("Home SEO"); - expect(page.isPublished()).toBe(false); - - page.publish("user-123"); - expect(page.isPublished()).toBe(true); - expect(page.extensions?.publishedBy).toBe("user-123"); - }); - }); -}); -``` - -```ts -// abstractions.ts -import { z } from "zod"; -import { createAbstraction } from "@webiny/features/api"; -import type { BaseModel } from "~/models/BaseModel.js"; -import type { IModelData } from "~/models/abstractions.js"; -import type { IPrivateCmsModelConfiguration } from "./PrivateCmsModelBuilder.js"; -import type { FieldDefinitionsFactory } from "./FieldDefinitionsBuilder.js"; -import { type TextFieldBuilder, type ObjectFieldBuilder, FieldBuilder } from "./FieldBuilder.js"; - -/** - * Field Builder Registry - provides field builders - */ -export interface IFieldBuilderRegistry { - text(): TextFieldBuilder; - object( - fields: (registry: IFieldBuilderRegistry) => { - [K in keyof TShape]: FieldBuilder; - } - ): ObjectFieldBuilder; -} - -export const FieldBuilderRegistry = createAbstraction("FieldBuilderRegistry"); - -/** - * Private CMS Model Builder - builds CMS model configurations - */ -export interface IPrivateCmsModelBuilder { - create>( - modelId: string, - fieldDefinitions: FieldDefinitionsFactory - ): IPrivateCmsModelConfiguration; -} - -export const PrivateCmsModelBuilder = createAbstraction( - "PrivateCmsModelBuilder" -); - -export namespace PrivateCmsModelBuilder { - export type Interface = IPrivateCmsModelBuilder; -} - -/** - * CMS Model Builder - builds the model configuration for a specific model - */ -export interface ICmsModelBuilder> { - buildCmsModel(): Promise>; -} - -/** - * CMS Model Factory - creates model instances - */ -export interface ICmsModelFactory> { - create(data: IModelData): Promise; -} -``` - -```ts -// types.ts -import { z } from "zod"; -import type { ModelClass } from "~/models/ModelBuilder.js"; -import type { BaseModel } from "~/models/BaseModel.js"; -import type { IModelData } from "~/models/abstractions.js"; - -export interface FieldConfig { - fieldId: string; - type: string; - label?: string; - helpText?: string; - placeholderText?: string; - validation?: ValidationRule[]; - settings?: Record; - zodSchema: z.ZodTypeAny; - fields?: FieldConfig[]; // For nested object fields -} - -export interface ValidationRule { - name: string; - message: string; - settings: Record; -} - -export interface CmsModelMetadata { - icon?: string; - description?: string; - titleFieldId?: string; - descriptionFieldId?: string; - imageFieldId?: string; - tags?: string[]; -} - -// Make PrivateCmsModel generic! -export interface PrivateCmsModel> { - type: "private"; - modelType: "private"; - Model: ModelClass; - modelId: string; - name: string; - icon?: string; - description?: string; - fields: FieldConfig[]; - schema: TModel["__schema"]; - create: (data: IModelData) => TModel; -} - -export interface FieldBuilderConfig { - fieldId: string; - config: FieldConfig; - zodSchema: z.ZodTypeAny; -} -``` diff --git a/ai-context/di-container.md b/ai-context/di-container.md index 1ea3a4e13a9..5adf82017cf 100644 --- a/ai-context/di-container.md +++ b/ai-context/di-container.md @@ -1,16 +1,61 @@ ```ts // Abstraction.ts +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { Constructor, Dependencies, GetInterface, MapDependencies } from "./types.js"; +import { Metadata } from "./Metadata.js"; + +type DropLast = T extends [...infer P, any] ? [...P] : never; + +type Implementation, I extends Constructor> = I & { + __abstraction: A; +}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars export class Abstraction { public readonly token: symbol; constructor(name: string) { - this.token = Symbol.for(name); + this.token = Symbol(name); } toString(): string { return this.token.description || this.token.toString(); } + + createImplementation>>(params: { + implementation: I; + dependencies: Dependencies; + }): Implementation { + const metadata = new Metadata(params.implementation); + metadata.setAbstraction(this); + metadata.setDependencies(params.dependencies); + + return params.implementation as Implementation; + } + + createDecorator(params: { + decorator: I; + dependencies: MapDependencies>>; + }): Implementation { + const metadata = new Metadata(params.decorator); + metadata.setAbstraction(this); + metadata.setDependencies(params.dependencies as any); + metadata.setAttribute("IS_DECORATOR", true); + + return params.decorator as Implementation; + } + + createComposite>>(params: { + implementation: I; + dependencies: Dependencies; + }): Implementation { + const metadata = new Metadata(params.implementation); + metadata.setAbstraction(this); + metadata.setDependencies(params.dependencies); + metadata.setAttribute("IS_COMPOSITE", true); + + return params.implementation as Implementation; + } } ``` diff --git a/ai-context/event-publisher.md b/ai-context/event-publisher.md index d5bcb2ed6fd..e31a8024d50 100644 --- a/ai-context/event-publisher.md +++ b/ai-context/event-publisher.md @@ -24,7 +24,7 @@ export class EventPublisher implements Abstraction.Interface { ```ts // __tests__/EventPublisher.test.ts import { describe, it, expect, beforeEach } from "vitest"; -import { Container, Abstraction, createImplementation } from "@webiny/di"; +import { Container, Abstraction } from "@webiny/di"; import { EventPublisherFeature } from "../feature"; import { EventPublisher as EventPublisherAbstraction } from "../abstractions"; import type { DomainEvent, IEventHandler } from "../abstractions"; @@ -123,8 +123,7 @@ class SendNotificationHandler implements IEventHandler { } } -export const SendNotificationHandlerImpl = createImplementation({ - abstraction: PagePublishedHandler, +export const SendNotificationHandlerImpl = PagePublishedHandler.createImplementation({ implementation: SendNotificationHandler, dependencies: [] }); @@ -138,8 +137,7 @@ class UpdateSearchIndexHandler implements IEventHandler { } } -export const UpdateSearchIndexHandlerImpl = createImplementation({ - abstraction: PagePublishedHandler, +export const UpdateSearchIndexHandlerImpl = PagePublishedHandler.createImplementation({ implementation: UpdateSearchIndexHandler, dependencies: [] }); @@ -155,8 +153,7 @@ class LogPagePublishedHandler implements IEventHandler { } } -export const LogPagePublishedHandlerImpl = createImplementation({ - abstraction: PagePublishedHandler, +export const LogPagePublishedHandlerImpl = PagePublishedHandler.createImplementation({ implementation: LogPagePublishedHandler, dependencies: [] }); @@ -174,8 +171,7 @@ class SendWelcomeEmailHandler implements IEventHandler { } } -export const SendWelcomeEmailHandlerImpl = createImplementation({ - abstraction: UserRegisteredHandler, +export const SendWelcomeEmailHandlerImpl = UserRegisteredHandler.createImplementation({ implementation: SendWelcomeEmailHandler, dependencies: [] }); @@ -189,8 +185,7 @@ class CreateUserProfileHandler implements IEventHandler { } } -export const CreateUserProfileHandlerImpl = createImplementation({ - abstraction: UserRegisteredHandler, +export const CreateUserProfileHandlerImpl = UserRegisteredHandler.createImplementation({ implementation: CreateUserProfileHandler, dependencies: [] }); @@ -204,8 +199,7 @@ class TrackUserRegistrationHandler implements IEventHandler } } -export const TrackUserRegistrationHandlerImpl = createImplementation({ - abstraction: UserRegisteredHandler, +export const TrackUserRegistrationHandlerImpl = UserRegisteredHandler.createImplementation({ implementation: TrackUserRegistrationHandler, dependencies: [] }); @@ -223,8 +217,7 @@ class ProcessPaymentHandler implements IEventHandler { } } -export const ProcessPaymentHandlerImpl = createImplementation({ - abstraction: OrderPlacedHandler, +export const ProcessPaymentHandlerImpl = OrderPlacedHandler.createImplementation({ implementation: ProcessPaymentHandler, dependencies: [] }); @@ -238,8 +231,7 @@ class SendOrderConfirmationHandler implements IEventHandler { } } -export const SendOrderConfirmationHandlerImpl = createImplementation({ - abstraction: OrderPlacedHandler, +export const SendOrderConfirmationHandlerImpl = OrderPlacedHandler.createImplementation({ implementation: SendOrderConfirmationHandler, dependencies: [] }); @@ -253,8 +245,7 @@ class UpdateInventoryHandler implements IEventHandler { } } -export const UpdateInventoryHandlerImpl = createImplementation({ - abstraction: OrderPlacedHandler, +export const UpdateInventoryHandlerImpl = OrderPlacedHandler.createImplementation({ implementation: UpdateInventoryHandler, dependencies: [] }); @@ -541,8 +532,7 @@ describe("EventPublisher", () => { } } - const FailingHandlerImpl = createImplementation({ - abstraction: PagePublishedHandler, + const FailingHandlerImpl = PagePublishedHandler.createImplementation({ implementation: FailingHandler, dependencies: [] }); diff --git a/ai-context/simple-models.md b/ai-context/simple-models.md index 6b7330b7fca..e7432e289b7 100644 --- a/ai-context/simple-models.md +++ b/ai-context/simple-models.md @@ -1,7 +1,6 @@ ```ts // Page/PageModelBuilder.ts import { ModelBuilder as Builder } from "~/models/ModelBuilder.js"; -import { createImplementation } from "@webiny/di"; import { PageSchema, PageModelBuilder as BuilderAbstraction, type IPage } from "./abstractions"; class PageModelBuilderImpl implements BuilderAbstraction.Interface { @@ -15,8 +14,7 @@ class PageModelBuilderImpl implements BuilderAbstraction.Interface { } } -export const PageModelBuilder = createImplementation({ - abstraction: BuilderAbstraction, +export const PageModelBuilder = BuilderAbstraction.createImplementation({ implementation: PageModelBuilderImpl, dependencies: [] }); @@ -24,7 +22,6 @@ export const PageModelBuilder = createImplementation({ ```ts // Page/PageModelFactory.ts -import { createImplementation } from "@webiny/di"; import { PageModelFactory as FactoryAbstraction, PageModelBuilder, @@ -49,8 +46,7 @@ class PageModelFactoryImpl implements FactoryAbstraction.Interface { } } -export const PageModelFactory = createImplementation({ - abstraction: FactoryAbstraction, +export const PageModelFactory = FactoryAbstraction.createImplementation({ implementation: PageModelFactoryImpl, dependencies: [PageModelBuilder] }); @@ -58,7 +54,6 @@ export const PageModelFactory = createImplementation({ ```ts // Page/__tests__/PageModelBuilderDecorator.ts -import { createDecorator } from "@webiny/di"; import { PageModelBuilder as BuilderAbstraction } from "../abstractions"; class PageModelBuilderDecoratorImpl implements BuilderAbstraction.Interface { @@ -81,8 +76,7 @@ class PageModelBuilderDecoratorImpl implements BuilderAbstraction.Interface { } } -export const PageModelBuilderDecorator = createDecorator({ - abstraction: BuilderAbstraction, +export const PageModelBuilderDecorator = BuilderAbstraction.createDecorator({ decorator: PageModelBuilderDecoratorImpl, dependencies: [] }); @@ -100,7 +94,6 @@ declare module "~/simple/Page/abstractions.js" { ```ts // Page/__tests__/PageModelBuilderDecorator2.ts -import { createDecorator } from "@webiny/di"; import { PageModelBuilder as BuilderAbstraction } from "../abstractions"; class PageModelBuilderDecorator2Impl implements BuilderAbstraction.Interface { @@ -117,8 +110,7 @@ class PageModelBuilderDecorator2Impl implements BuilderAbstraction.Interface { } } -export const PageModelBuilderDecorator2 = createDecorator({ - abstraction: BuilderAbstraction, +export const PageModelBuilderDecorator2 = BuilderAbstraction.createDecorator({ decorator: PageModelBuilderDecorator2Impl, dependencies: [] }); diff --git a/package.json b/package.json index 5cac30ad637..18241d83c53 100644 --- a/package.json +++ b/package.json @@ -258,7 +258,7 @@ "packageManager": "yarn@4.10.2", "dependencies": { "@types/hoist-non-react-statics": "^3.3.7", - "@webiny/di": "^0.1.1" + "@webiny/di": "^0.2.0" }, "engines": { "node": ">=22.0.0" diff --git a/packages/api-aco/package.json b/packages/api-aco/package.json index edf338bd406..c30df850ac8 100644 --- a/packages/api-aco/package.json +++ b/packages/api-aco/package.json @@ -35,7 +35,7 @@ "@webiny/api-headless-cms": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/db-dynamodb": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", diff --git a/packages/api-core/package.json b/packages/api-core/package.json index cc344ef94c8..e367cad1004 100644 --- a/packages/api-core/package.json +++ b/packages/api-core/package.json @@ -60,7 +60,7 @@ "license": "MIT", "dependencies": { "@webiny/api": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", diff --git a/packages/api-core/src/features/security/apiKeys/CreateApiKey/CreateApiKeyUseCase.ts b/packages/api-core/src/features/security/apiKeys/CreateApiKey/CreateApiKeyUseCase.ts index 73fde3ece90..85ca3679002 100644 --- a/packages/api-core/src/features/security/apiKeys/CreateApiKey/CreateApiKeyUseCase.ts +++ b/packages/api-core/src/features/security/apiKeys/CreateApiKey/CreateApiKeyUseCase.ts @@ -1,5 +1,4 @@ import { mdbid } from "@webiny/utils"; -import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import { TenantContext } from "~/features/tenancy/TenantContext/index.js"; import { EventPublisher } from "~/features/eventPublisher/index.js"; @@ -73,8 +72,7 @@ export class CreateApiKeyUseCase { } } -export const CreateApiKeyUseCaseImpl = createImplementation({ - abstraction: CreateApiKey, +export const CreateApiKeyUseCaseImpl = CreateApiKey.createImplementation({ implementation: CreateApiKeyUseCase, dependencies: [TenantContext, IdentityContext, EventPublisher, ApiKeysRepository] }); diff --git a/packages/api-headless-cms/package.json b/packages/api-headless-cms/package.json index 492fd03228d..745124548c5 100644 --- a/packages/api-headless-cms/package.json +++ b/packages/api-headless-cms/package.json @@ -24,7 +24,7 @@ "@graphql-tools/schema": "^10.0.25", "@webiny/api": "0.0.0", "@webiny/api-core": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", diff --git a/packages/api/package.json b/packages/api/package.json index 6e3fa73030d..07a8e719d02 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,7 +14,7 @@ "license": "MIT", "dependencies": { "@webiny/aws-sdk": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0" }, diff --git a/packages/app-admin/package.json b/packages/app-admin/package.json index 47c57176277..14081aa9c17 100644 --- a/packages/app-admin/package.json +++ b/packages/app-admin/package.json @@ -29,7 +29,7 @@ "@webiny/admin-ui": "0.0.0", "@webiny/app": "0.0.0", "@webiny/app-security": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/feature": "0.0.0", "@webiny/form": "0.0.0", "@webiny/icons": "0.0.0", diff --git a/packages/app/package.json b/packages/app/package.json index 3962165bfce..f31f5e4b04b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -26,7 +26,7 @@ "@apollo/react-hooks": "^3.1.5", "@emotion/styled": "11.10.6", "@types/react": "18.2.79", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/i18n": "0.0.0", "@webiny/i18n-react": "0.0.0", "@webiny/plugins": "0.0.0", diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 49d7df42149..97398658e57 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -10,7 +10,7 @@ "description": "A command line interface (CLI) for managing Webiny projects.", "license": "MIT", "dependencies": { - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/logger": "0.0.0", "@webiny/project": "0.0.0", "@webiny/pulumi-sdk": "0.0.0", diff --git a/packages/feature/package.json b/packages/feature/package.json index b768feb0524..1263021064c 100644 --- a/packages/feature/package.json +++ b/packages/feature/package.json @@ -14,7 +14,7 @@ "./admin": "./admin/index.js" }, "dependencies": { - "@webiny/di": "^0.1.1" + "@webiny/di": "^0.2.0" }, "devDependencies": { "@webiny/build-tools": "0.0.0", diff --git a/packages/project-aws/package.json b/packages/project-aws/package.json index 6390cf0a6dc..ffbf07a5938 100644 --- a/packages/project-aws/package.json +++ b/packages/project-aws/package.json @@ -52,7 +52,7 @@ "@webiny/cli-core": "0.0.0", "@webiny/data-migration": "0.0.0", "@webiny/db-dynamodb": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/handler-aws": "0.0.0", "@webiny/handler-db": "0.0.0", "@webiny/handler-graphql": "0.0.0", diff --git a/packages/project/package.json b/packages/project/package.json index 1837e0f022c..d084ee2ab39 100644 --- a/packages/project/package.json +++ b/packages/project/package.json @@ -12,7 +12,7 @@ "dependencies": { "@webiny/aws-sdk": "0.0.0", "@webiny/build-tools": "0.0.0", - "@webiny/di": "^0.1.1", + "@webiny/di": "^0.2.0", "@webiny/global-config": "0.0.0", "@webiny/logger": "0.0.0", "@webiny/pulumi-sdk": "0.0.0", diff --git a/yarn.lock b/yarn.lock index e0a0b60dfbd..04954ed249d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14303,7 +14303,7 @@ __metadata: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" @@ -14424,7 +14424,7 @@ __metadata: "@webiny/api": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" @@ -14876,7 +14876,7 @@ __metadata: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" @@ -15163,7 +15163,7 @@ __metadata: dependencies: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" "@webiny/utils": "npm:0.0.0" @@ -15383,7 +15383,7 @@ __metadata: "@webiny/app": "npm:0.0.0" "@webiny/app-security": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/feature": "npm:0.0.0" "@webiny/form": "npm:0.0.0" "@webiny/icons": "npm:0.0.0" @@ -16063,7 +16063,7 @@ __metadata: "@types/universal-router": "npm:^8.0.0" "@types/warning": "npm:^3.0.3" "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/i18n": "npm:0.0.0" "@webiny/i18n-react": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" @@ -16214,7 +16214,7 @@ __metadata: "@types/listr": "npm:^0.14.9" "@types/lodash": "npm:4.17.20" "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/logger": "npm:0.0.0" "@webiny/project": "npm:0.0.0" "@webiny/pulumi-sdk": "npm:0.0.0" @@ -16331,12 +16331,12 @@ __metadata: languageName: unknown linkType: soft -"@webiny/di@npm:^0.1.1": - version: 0.1.1 - resolution: "@webiny/di@npm:0.1.1" +"@webiny/di@npm:^0.2.0": + version: 0.2.0 + resolution: "@webiny/di@npm:0.2.0" dependencies: reflect-metadata: "npm:^0.2.2" - checksum: 10/d24114b5f1c70a31494177438d2b5bc2b4ff55d087d9bc79972846ddfbf4d01173baa610d59bacc5393e56f37adbc80f9e312805bfd8c534c2b4948659168d47 + checksum: 10/bdf2e4a1d0d7e165b7210c507e8ba156d6c98c965fda043396adeabc2a40c0bc3c558e1088fbea2ee71bb273a5baad5d6060efba4bce35f016d4712e4fa633c6 languageName: node linkType: hard @@ -16378,7 +16378,7 @@ __metadata: resolution: "@webiny/feature@workspace:packages/feature" dependencies: "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" typescript: "npm:5.9.3" languageName: unknown linkType: soft @@ -16750,7 +16750,7 @@ __metadata: "@webiny/cli-core": "npm:0.0.0" "@webiny/data-migration": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-db": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" @@ -16811,7 +16811,7 @@ __metadata: "@types/read-json-sync": "npm:^2.0.3" "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" "@webiny/global-config": "npm:0.0.0" "@webiny/logger": "npm:0.0.0" "@webiny/pulumi-sdk": "npm:0.0.0" @@ -33156,7 +33156,7 @@ __metadata: "@typescript-eslint/parser": "npm:^8.46.0" "@vitest/coverage-v8": "npm:^3.2.4" "@vitest/eslint-plugin": "npm:^1.3.16" - "@webiny/di": "npm:^0.1.1" + "@webiny/di": "npm:^0.2.0" adio: "npm:^2.0.1" axios: "npm:^1.12.2" babel-loader: "npm:^10.0.0" From 182323d3a55998cf164e7ec71c16cfd561ec2b17 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 14 Nov 2025 21:19:25 +0100 Subject: [PATCH 20/71] wip: migrate content model crud --- .../src/domain/contentModel/errors.ts | 10 + .../src/domain/contentModel/validation.ts | 287 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation.ts diff --git a/packages/api-headless-cms/src/domain/contentModel/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts index 47e4d51e6c7..ba53520cb22 100644 --- a/packages/api-headless-cms/src/domain/contentModel/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModel/errors.ts @@ -76,3 +76,13 @@ export class ModelCannotDeleteCodeDefinedError extends BaseError { }); } } + +export class ModelSlugTakenError extends BaseError { + override readonly code = "Cms/Model/SlugTaken" as const; + + constructor(slug: string) { + super({ + message: `Model slug/API name "${slug}" is already taken.` + }); + } +} diff --git a/packages/api-headless-cms/src/domain/contentModel/validation.ts b/packages/api-headless-cms/src/domain/contentModel/validation.ts new file mode 100644 index 00000000000..b2203dd8cb6 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation.ts @@ -0,0 +1,287 @@ +import zod from "zod"; +import upperFirst from "lodash/upperFirst.js"; +import camelCase from "lodash/camelCase.js"; + +const fieldSystemFields: string[] = [ + "id", + "entryId", + "createdOn", + "modifiedOn", + "publishedOn", + "savedOn", + "deletedOn", + "restoredOn", + "firstPublishedOn", + "lastPublishedOn", + "createdBy", + "modifiedBy", + "savedBy", + "deletedBy", + "restoredBy", + "firstPublishedBy", + "lastPublishedBy", + "revisionCreatedOn", + "revisionModifiedOn", + "revisionSavedOn", + "revisionDeletedOn", + "revisionRestoredOn", + "revisionFirstPublishedOn", + "revisionLastPublishedOn", + "revisionCreatedBy", + "revisionModifiedBy", + "revisionSavedBy", + "revisionDeletedBy", + "revisionRestoredBy", + "revisionFirstPublishedBy", + "revisionLastPublishedBy", + "meta", + "wbyAco_location" +]; + +const str = zod.string().trim(); +const shortString = str.max(255); +const optionalShortString = shortString.optional(); +const optionalNullishShortString = optionalShortString.nullish(); + +const fieldSchema = zod.object({ + id: shortString, + storageId: zod + .string() + .optional() + .transform(() => { + return ""; + }), + fieldId: shortString + .max(100) + .regex(/^!?[a-zA-Z]/, { + message: `Provided value is not valid - must not start with a number.` + }) + .regex(/^(^[a-zA-Z0-9]+)$/, { + message: `Provided value is not valid - must be alphanumeric string.` + }) + .superRefine((value, ctx) => { + if (fieldSystemFields.includes(value)) { + return ctx.addIssue({ + code: zod.ZodIssueCode.custom, + message: `Field ID "${value}" is a reserved keyword, and is not allowed.` + }); + } + }), + label: shortString, + helpText: optionalShortString.optional().nullish().default(null), + placeholderText: optionalShortString.optional().nullable().default(null), + type: shortString, + tags: zod.array(shortString).optional().default([]), + multipleValues: zod + .boolean() + .optional() + .nullish() + .transform(value => { + return !!value; + }) + .default(false), + predefinedValues: zod + .object({ + enabled: zod.boolean(), + values: zod + .array( + zod.object({ + value: shortString, + label: shortString, + selected: zod.boolean().optional().default(false) + }) + ) + .default([]) + }) + .default({ + enabled: false, + values: [] + }) + .nullish() + .optional() + .transform(value => { + return value || undefined; + }), + renderer: zod + .object({ + name: shortString, + settings: zod.object({}).passthrough().nullish().optional() + }) + .optional() + .nullable() + .default(null), + validation: zod + .array( + zod.object({ + name: shortString, + message: optionalShortString.default("Value is required."), + settings: zod + .object({}) + .passthrough() + .optional() + .nullish() + .transform(value => { + return value || {}; + }) + .default({}) + }) + ) + .nullish() + .optional() + .default([]) + .transform(value => value || []), + listValidation: zod + .array( + zod.object({ + name: shortString, + message: optionalShortString.default("Value is required."), + settings: zod + .object({}) + .passthrough() + .optional() + .nullish() + .transform(value => { + return value || {}; + }) + .default({}) + }) + ) + .nullish() + .optional() + .default([]) + .transform(value => value || []), + settings: zod + .object({}) + .passthrough() + .optional() + .nullish() + .transform(value => { + return value || {}; + }) + .default({}) +}); + +const apiNameRefinementValidation = (value: string): boolean => { + if (value.match(/^[A-Z]/) === null) { + return false; + } + return value === upperFirst(camelCase(value)); +}; +const refinementSingularValidationMessage = (value?: string) => { + return { + message: `The Singular API Name value "${ + value || "undefined" + }" is not valid. It must in Upper First + Camel Cased form. For example: "ArticleCategory" or "CarMake".` + }; +}; +const refinementPluralValidationMessage = (value?: string) => { + return { + message: `The Plural API Name value "${ + value || "undefined" + }" is not valid. It must in Upper First + Camel Cased form. For example: "ArticleCategories" or "CarMakes".` + }; +}; + +const refinementModelIdValidation = (value?: string) => { + if (!value) { + return true; + } else if (value.match(/^[a-zA-Z]/) === null) { + return false; + } + const camelCasedValue = camelCase(value).toLowerCase(); + return camelCasedValue === value.toLowerCase(); +}; +const refinementModelIdValidationMessage = (value?: string) => { + if (!value) { + return {}; + } else if (value.match(/^[a-zA-Z]/) === null) { + return { + message: `The modelId "${value}" is not valid. It must start with a A-Z or a-z.` + }; + } + return { + message: `The modelId "${value}" is not valid.` + }; +}; + +const modelIdTransformation = (value?: string) => { + if (!value) { + return value; + } + const camelCasedValue = camelCase(value); + if (camelCasedValue.toLowerCase() === value.toLowerCase()) { + return value; + } + return camelCasedValue; +}; + +export const createModelCreateValidation = () => { + return zod.object({ + name: shortString, + modelId: optionalShortString.transform(modelIdTransformation), + singularApiName: shortString + .min(1) + .refine(apiNameRefinementValidation, refinementSingularValidationMessage), + pluralApiName: shortString + .min(1) + .refine(apiNameRefinementValidation, refinementPluralValidationMessage), + description: optionalNullishShortString.transform(value => { + return value || ""; + }), + group: shortString, + icon: optionalNullishShortString, + fields: zod.array(fieldSchema).default([]), + layout: zod.array(zod.array(shortString)).default([]), + tags: zod.array(shortString).optional(), + titleFieldId: optionalShortString.nullish(), + descriptionFieldId: optionalShortString.nullish(), + imageFieldId: optionalShortString.nullish(), + defaultFields: zod.boolean().nullish() + }); +}; + +export const createModelUpdateValidation = () => { + return zod.object({ + name: optionalShortString, + singularApiName: optionalShortString.refine(value => { + if (!value) { + return true; + } + return apiNameRefinementValidation(value); + }, refinementSingularValidationMessage), + pluralApiName: optionalShortString.refine(value => { + if (!value) { + return true; + } + return apiNameRefinementValidation(value); + }, refinementPluralValidationMessage), + description: optionalNullishShortString, + group: optionalShortString, + icon: optionalNullishShortString, + fields: zod.array(fieldSchema), + layout: zod.array(zod.array(shortString)), + titleFieldId: optionalShortString.nullish(), + descriptionFieldId: optionalShortString.nullish(), + imageFieldId: optionalShortString.nullish(), + tags: zod.array(shortString).optional() + }); +}; + +export const createModelCreateFromValidation = () => { + return zod.object({ + name: shortString, + modelId: optionalShortString.transform(modelIdTransformation), + singularApiName: shortString.refine( + apiNameRefinementValidation, + refinementSingularValidationMessage + ), + pluralApiName: shortString.refine( + apiNameRefinementValidation, + refinementPluralValidationMessage + ), + description: optionalNullishShortString, + group: shortString, + icon: optionalNullishShortString, + locale: optionalShortString + }); +}; From 9d7201f9a1d6def83f5be5a1284d54c18db6514d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 14 Nov 2025 22:20:19 +0100 Subject: [PATCH 21/71] wip: migrate content model crud --- packages/api-headless-cms/src/context.ts | 2 + .../src/crud/contentEntry.crud.ts | 20 --- .../contentModel/ContentModelFeature.ts | 18 +++ .../shared/ModelToAstConverter.ts | 34 +++++ .../contentModel/shared/ModelsRepository.ts | 141 ------------------ .../shared/PluginModelsProvider.ts | 54 ++++--- .../contentModel/shared/abstractions.ts | 84 +++-------- .../ContentModelGroupFeature.ts | 5 +- .../contentModelGroup/shared/GroupCache.ts | 31 ---- .../contentModelGroup/shared/abstractions.ts | 28 +--- .../src/features/shared/abstractions.ts | 5 + 11 files changed, 120 insertions(+), 302 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts delete mode 100644 packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts delete mode 100644 packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index 196272ad803..d8ff40fc645 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -31,6 +31,7 @@ import { import { entryFromStorageTransform, entryToStorageTransform } from "~/utils/entryStorage.js"; import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; import { ContentModelGroupFeature } from "~/features/contentModelGroup/ContentModelGroupFeature.js"; +import { ContentModelFeature } from "~/features/contentModel/ContentModelFeature.js"; const getParameters = async (context: CmsContext): Promise => { const plugins = context.plugins.byType(CmsParametersPlugin.type); @@ -173,6 +174,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { // Register features CmsInstallerFeature.register(context.container, context.cms); ContentEntriesFeature.register(context.container); + ContentModelFeature.register(context.container); ContentModelGroupFeature.register(context.container); if (!storageOperations.init) { diff --git a/packages/api-headless-cms/src/crud/contentEntry.crud.ts b/packages/api-headless-cms/src/crud/contentEntry.crud.ts index 77d468d05b0..7fba9e7fd38 100644 --- a/packages/api-headless-cms/src/crud/contentEntry.crud.ts +++ b/packages/api-headless-cms/src/crud/contentEntry.crud.ts @@ -46,9 +46,6 @@ import type { UpdateCmsEntryOptionsInput } from "~/types/index.js"; import { createTopic } from "@webiny/pubsub"; -import { assignBeforeEntryCreate } from "./contentEntry/beforeCreate.js"; -import { assignBeforeEntryUpdate } from "./contentEntry/beforeUpdate.js"; -import { assignAfterEntryDelete } from "./contentEntry/afterDelete.js"; import { ContentEntryTraverser } from "~/utils/contentEntryTraverser/ContentEntryTraverser.js"; import type { GenericRecord } from "@webiny/api/types.js"; import { CreateEntryUseCase } from "~/features/contentEntry/CreateEntry/index.js"; @@ -203,23 +200,6 @@ export const createContentEntryCrud = (params: CreateContentEntryCrudParams): Cm */ const onEntryBeforeList = createTopic("cms.onEntryBeforeList"); - /** - * We need to assign some default behaviors. - * TODO: move this to a separate feature with multiple event handlers and field locking. - */ - assignBeforeEntryCreate({ - context, - onEntryBeforeCreate - }); - assignBeforeEntryUpdate({ - context, - onEntryBeforeUpdate - }); - assignAfterEntryDelete({ - context, - onEntryAfterDelete - }); - const createEntry: CmsEntryContext["createEntry"] = async ( model: CmsModel, rawInput: CreateCmsEntryInput, diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts new file mode 100644 index 00000000000..bbd162df980 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -0,0 +1,18 @@ +import { createFeature } from "@webiny/feature/api"; +import { createMemoryCache } from "~/utils/index.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/PluginModelsProvider.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; + +export const ContentModelFeature = createFeature({ + name: "ContentModel", + register(container) { + container.registerInstance(ModelCache, createMemoryCache()); + container.register(PluginModelsProvider).inSingletonScope(); + container.register(ModelToAstConverter); + + // Query features + + // Command features + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts new file mode 100644 index 00000000000..84dca0b1667 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts @@ -0,0 +1,34 @@ +import { ModelToAstConverter as ConverterAbstraction } from "./abstractions.js"; +import { + CmsModelToAstConverter, + CmsModelFieldToAstConverterFromPlugins +} from "~/utils/contentModelAst/index.js"; +import type { CmsModel, CmsModelAst, CmsModelFieldToGraphQLPlugin } from "~/types/index.js"; +import { PluginsContainer } from "~/legacy/abstractions.js"; + +/** + * ModelToAstConverter implementation + * + * Wraps CmsModelToAstConverter and provides it with field type plugins + * for converting models to AST representation (used for GraphQL schema generation) + */ +class ModelToAstConverterImpl implements ConverterAbstraction.Interface { + constructor(private pluginsContainer: PluginsContainer.Interface) {} + + toAST(model: CmsModel): CmsModelAst { + const fieldTypePlugins = this.pluginsContainer.byType( + "cms-model-field-to-graphql" + ); + + const converter = new CmsModelToAstConverter( + new CmsModelFieldToAstConverterFromPlugins(fieldTypePlugins) + ); + + return converter.toAst(model); + } +} + +export const ModelToAstConverter = ConverterAbstraction.createImplementation({ + implementation: ModelToAstConverterImpl, + dependencies: [PluginsContainer] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts deleted file mode 100644 index 3ce2a35d21e..00000000000 --- a/packages/api-headless-cms/src/features/contentModel/shared/ModelsRepository.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Result } from "@webiny/feature/api"; -import { createImplementation } from "@webiny/feature/api"; -import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { ModelsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { PluginModelsProvider } from "./abstractions.js"; -import { - ModelNotFoundError, - ModelStorageError, - ModelCannotUpdateCodeDefinedError, - ModelCannotDeleteCodeDefinedError -} from "~/domain/contentModel/errors.js"; -import type { CmsModel } from "~/types/index.js"; -import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; - -/** - * ModelsRepository implementation following CQS principle. - * Provides unified access to both database-stored and plugin-defined models. - */ -class ModelsRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private tenantContext: TenantContext.Interface, - private pluginModelsProvider: PluginModelsProvider.Interface, - private accessControl: AccessControl.Interface, - private storageOperations: StorageOperations.Interface - ) {} - - async get(modelId: string): Promise> { - try { - // 1. Check plugin models first (code-defined, immutable) TODO: move to decorator! - const pluginModels = await this.pluginModelsProvider.getModels(); - const pluginModel = pluginModels.find(m => m.modelId === modelId); - - if (pluginModel) { - // Apply access control - const canAccess = await this.accessControl.canAccessModel({ model: pluginModel }); - if (!canAccess) { - return Result.fail(new ModelNotFoundError(modelId)); - } - return Result.ok(pluginModel); - } - - // 2. Query database models - const tenant = this.tenantContext.getTenant(); - const dbModel = await this.storageOperations.models.get({ tenant: tenant.id, modelId }); - if (!dbModel) { - return Result.fail(new ModelNotFoundError(modelId)); - } - - // Apply access control - const canAccess = await this.accessControl.canAccessModel({ model: dbModel }); - if (!canAccess) { - return Result.fail(new ModelNotFoundError(modelId)); - } - - return Result.ok(dbModel); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async list(): Promise> { - try { - // 1. Get plugin models TODO: move to decorator! - const pluginModels = await this.pluginModelsProvider.getModels(); - - // 2. Get DB models - const tenant = this.tenantContext.getTenant(); - const dbModels = await this.storageOperations.models.list({ - where: { tenant: tenant.id } - }); - - // 3. Combine both sources TODO: move to decorator! - const allModels = [...pluginModels, ...dbModels]; - - // 4. Apply access control to all models - const accessibleModels: CmsModel[] = []; - for (const model of allModels) { - const canAccess = await this.accessControl.canAccessModel({ model }); - if (canAccess) { - accessibleModels.push(model); - } - } - - return Result.ok(accessibleModels); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async create(model: CmsModel): Promise> { - try { - const tenant = this.tenantContext.getTenant(); - model.tenant = tenant.id; - await this.storageOperations.models.create({ model }); - return Result.ok(); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async update(model: CmsModel): Promise> { - try { - // Cannot update plugin models. TODO: move to decorator! - const pluginModels = await this.pluginModelsProvider.getModels(); - const isPluginModel = pluginModels.some(m => m.modelId === model.modelId); - - if (isPluginModel) { - return Result.fail(new ModelCannotUpdateCodeDefinedError(model.modelId)); - } - - await this.storageOperations.models.update({ model }); - return Result.ok(); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async delete(model: CmsModel): Promise> { - try { - // Cannot delete plugin models TODO: move to decorator! - const pluginModels = await this.pluginModelsProvider.getModels(); - const isPluginModel = pluginModels.some(m => m.modelId === model.modelId); - - if (isPluginModel) { - return Result.fail(new ModelCannotDeleteCodeDefinedError(model.modelId)); - } - - await this.storageOperations.models.delete({ model }); - return Result.ok(); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } -} - -export const ModelsRepository = createImplementation({ - abstraction: RepositoryAbstraction, - implementation: ModelsRepositoryImpl, - dependencies: [TenantContext, PluginModelsProvider, AccessControl, StorageOperations] -}); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts b/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts index 1b8d541c442..d441ff825f3 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts @@ -1,35 +1,49 @@ +import { AccessControl, CmsContext } from "~/features/shared/abstractions.js"; import { PluginModelsProvider as ProviderAbstraction } from "./abstractions.js"; -import { TenantContext } from "@webiny/api-core/features/TenantContext"; import type { CmsModel } from "~/types/index.js"; - -export type GetCmsModels = () => CmsModel[]; +import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; +import { ensureTypeTag } from "~/crud/contentModel/ensureTypeTag.js"; +import { filterAsync } from "~/utils/filterAsync.js"; /** - * PluginModelsProvider implementation that fetches models from plugins. + * PluginModelsProvider implementation that fetches models from CmsModelPlugin instances */ -export class PluginModelsProvider implements ProviderAbstraction.Interface { +class PluginModelsProviderImpl implements ProviderAbstraction.Interface { constructor( - private tenantContext: TenantContext.Interface, - private getCmsModels: GetCmsModels + private cmsContext: CmsContext.Interface, + private accessControl: AccessControl.Interface ) {} - async getModels(): Promise { - const tenant = this.tenantContext.getTenant(); - - const models = this.getCmsModels(); + async list(tenant: string): Promise { + const modelPlugins = this.cmsContext.plugins.byType(CmsModelPlugin.type); - return models - .filter(model => { - // Filter by tenant/locale if specified in plugin - // If not specified, plugin model is available for all tenants/locales - if (model.tenant && model.tenant !== tenant.id) { + const models = modelPlugins + .filter(plugin => { + const { tenant: modelTenant } = plugin.contentModel; + // Filter by tenant if specified in plugin + if (modelTenant && modelTenant !== tenant) { return false; } + return true; }) - .map(model => ({ - ...model, - tenant: tenant.id - })) as CmsModel[]; + .map(plugin => { + return { + ...plugin.contentModel, + tags: ensureTypeTag(plugin.contentModel), + tenant, + webinyVersion: this.cmsContext.WEBINY_VERSION + }; + }) as unknown as CmsModel[]; + + // Apply access control filtering + return filterAsync(models, model => { + return this.accessControl.canAccessModel({ model }); + }); } } + +export const PluginModelsProvider = ProviderAbstraction.createImplementation({ + implementation: PluginModelsProviderImpl, + dependencies: [CmsContext, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index b580eb5f5fc..61b40a644e0 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -1,82 +1,36 @@ import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import type { CmsModel } from "~/types/index.js"; -import type { - ModelNotFoundError, - ModelStorageError, - ModelCannotUpdateCodeDefinedError, - ModelCannotDeleteCodeDefinedError -} from "~/domain/contentModel/errors.js"; - -export interface IModelsRepositoryErrors { - base: - | ModelNotFoundError - | ModelStorageError - | ModelCannotUpdateCodeDefinedError - | ModelCannotDeleteCodeDefinedError; -} - -type RepositoryError = IModelsRepositoryErrors[keyof IModelsRepositoryErrors]; +import type { CmsModel, CmsModelAst } from "~/types/index.js"; +import type { ICache } from "~/utils/caching/types.js"; /** - * ModelsRepository follows CQS (Command-Query Separation): - * - Queries (get, list): Return data wrapped in Result - * - Commands (create, update, delete): Return Result - * - * This repository provides unified access to both database-stored models - * and plugin-defined (code) models, transparently handling access control. + * PluginModelsProvider provides access to plugin-defined (code) models. */ -export interface IModelsRepository { - /** - * Get a single model by ID. - * Checks plugin models first, then database models. - * Applies access control. - */ - get(modelId: string): Promise>; - - /** - * List all accessible models. - * Combines plugin models and database models. - * Applies access control to all results. - */ - list(): Promise>; - - /** - * Create a new model in the database. - * Plugin models cannot be created (they are code-defined). - */ - create(model: CmsModel): Promise>; +export interface IPluginModelsProvider { + list(tenant: string, locale: string): Promise; +} - /** - * Update an existing database model. - * Plugin models cannot be updated. - */ - update(model: CmsModel): Promise>; +export const PluginModelsProvider = + createAbstraction("PluginModelsProvider"); - /** - * Delete a database model. - * Plugin models cannot be deleted. - */ - delete(model: CmsModel): Promise>; +export namespace PluginModelsProvider { + export type Interface = IPluginModelsProvider; } -export const ModelsRepository = createAbstraction("ModelsRepository"); +export const ModelCache = createAbstraction>>("ModelCache"); -export namespace ModelsRepository { - export type Interface = IModelsRepository; - export type Error = RepositoryError; +export namespace ModelCache { + export type Interface = ICache; } /** - * PluginModelsProvider provides access to plugin-defined (code) models. + * Convert model to AST */ -export interface IPluginModelsProvider { - getModels(): Promise; +export interface IModelToAstConverter { + toAST(model: CmsModel): CmsModelAst; } -export const PluginModelsProvider = - createAbstraction("PluginModelsProvider"); +export const ModelToAstConverter = createAbstraction("ModelToAstConverter"); -export namespace PluginModelsProvider { - export type Interface = IPluginModelsProvider; +export namespace ModelToAstConverter { + export type Interface = IModelToAstConverter; } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts index 532350ecfeb..a8fd8f3a2b1 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ContentModelGroupFeature.ts @@ -4,14 +4,15 @@ import { ListGroupsFeature } from "~/features/contentModelGroup/ListGroups/featu import { CreateGroupFeature } from "~/features/contentModelGroup/CreateGroup/feature.js"; import { UpdateGroupFeature } from "~/features/contentModelGroup/UpdateGroup/feature.js"; import { DeleteGroupFeature } from "~/features/contentModelGroup/DeleteGroup/feature.js"; -import { GroupCache } from "~/features/contentModelGroup/shared/GroupCache.js"; +import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/PluginGroupsProvider.js"; +import { createMemoryCache } from "~/utils/index.js"; export const ContentModelGroupFeature = createFeature({ name: "ContentModelGroup", register(container) { // Shared infrastructure (singletons) - container.register(GroupCache).inSingletonScope(); + container.registerInstance(GroupCache, createMemoryCache()); container.register(PluginGroupsProvider).inSingletonScope(); // Query features diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts deleted file mode 100644 index aab28475690..00000000000 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createImplementation } from "@webiny/feature/api"; -import { GroupCache as GroupCacheAbstraction } from "./abstractions.js"; -import { createMemoryCache } from "~/utils/index.js"; -import type { ICacheKey } from "~/utils/caching/types.js"; - -/** - * GroupCache implementation - * - * A simple promise deduplication cache that prevents duplicate concurrent requests. - * Repositories are responsible for: - * - Creating cache keys - * - Providing data loader functions - * - Implementing the actual data fetching logic - */ -class GroupCacheImpl implements GroupCacheAbstraction.Interface { - private cache = createMemoryCache>(); - - getOrSet(cacheKey: ICacheKey, loader: () => Promise): Promise { - return this.cache.getOrSet(cacheKey, loader); - } - - clear(): void { - this.cache.clear(); - } -} - -export const GroupCache = createImplementation({ - abstraction: GroupCacheAbstraction, - implementation: GroupCacheImpl, - dependencies: [] -}); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts index 6a67eff9e9b..e6a9aabca9b 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/abstractions.ts @@ -1,6 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; -import type { ICacheKey } from "~/utils/caching/types.js"; +import type { ICache } from "~/utils/caching/types.js"; /** * PluginGroupsProvider provides access to plugin-defined (code) groups. @@ -9,33 +9,15 @@ export interface IPluginGroupsProvider { getGroups(): Promise; } -export const PluginGroupsProvider = createAbstraction( - "PluginGroupsProvider" -); +export const PluginGroupsProvider = + createAbstraction("PluginGroupsProvider"); export namespace PluginGroupsProvider { export type Interface = IPluginGroupsProvider; } -/** - * GroupCache abstraction - Simple promise deduplication cache - */ -export interface IGroupCache { - /** - * Get or set a value in the cache using a loader function. - * If a promise is already pending for this key, returns the existing promise. - * Otherwise, executes the loader and caches the promise. - */ - getOrSet(cacheKey: ICacheKey, loader: () => Promise): Promise; - - /** - * Clear all cached promises. Should be called after create/update/delete operations. - */ - clear(): void; -} - -export const GroupCache = createAbstraction("GroupCache"); +export const GroupCache = createAbstraction>>("GroupCache"); export namespace GroupCache { - export type Interface = IGroupCache; + export type Interface = ICache; } diff --git a/packages/api-headless-cms/src/features/shared/abstractions.ts b/packages/api-headless-cms/src/features/shared/abstractions.ts index 8a3a45844b6..0beb30cf70e 100644 --- a/packages/api-headless-cms/src/features/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/shared/abstractions.ts @@ -32,3 +32,8 @@ export const CmsContext = createAbstraction("CmsContext"); export namespace CmsContext { export type Interface = CmsCtx; } + +export interface IAccessControl { + canAccessModel(params: { model: any }): Promise; + canAccessGroup(params: { group: any }): Promise; +} From f024815c8afa2de62c475419db4adf4e2a5cdd4c Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 16 Nov 2025 20:48:59 +0100 Subject: [PATCH 22/71] wip: migrate content model crud --- ai-context/backend-developer-guide.md | 274 +++++++++--------- .../src/crud/contentModel.crud.ts | 116 +------- .../contentModel/listModelsFromDatabase.ts | 5 - .../src/domain/contentEntry/errors.ts | 4 +- .../src/domain/contentModel/ensureTypeTag.ts | 15 + .../src/domain/contentModel/errors.ts | 4 +- .../src/domain/contentModelGroup/errors.ts | 4 +- .../CreateEntry/CreateEntryRepository.ts | 4 +- .../contentEntry/CreateEntry/abstractions.ts | 4 +- .../CreateEntryRevisionFromRepository.ts | 4 +- .../CreateEntryRevisionFrom/abstractions.ts | 6 +- .../DeleteEntry/DeleteEntryRepository.ts | 4 +- .../DeleteEntry/MoveEntryToBinRepository.ts | 4 +- .../contentEntry/DeleteEntry/abstractions.ts | 6 +- .../decorators/ForceDeleteDecorator.ts | 4 +- .../DeleteEntryRevisionRepository.ts | 4 +- .../DeleteEntryRevision/abstractions.ts | 6 +- .../DeleteMultipleEntriesRepository.ts | 4 +- .../DeleteMultipleEntries/abstractions.ts | 6 +- .../GetEntriesByIdsRepository.ts | 4 +- .../GetEntriesByIds/abstractions.ts | 6 +- .../contentEntry/GetEntry/abstractions.ts | 4 +- .../contentEntry/GetEntryById/abstractions.ts | 4 +- .../GetLatestEntriesByIdsRepository.ts | 4 +- .../GetLatestEntriesByIds/abstractions.ts | 6 +- .../GetLatestRevisionByEntryIdRepository.ts | 4 +- .../abstractions.ts | 6 +- .../GetPreviousRevisionByEntryIdRepository.ts | 4 +- .../abstractions.ts | 6 +- .../GetPublishedEntriesByIdsRepository.ts | 4 +- .../GetPublishedEntriesByIds/abstractions.ts | 6 +- ...GetPublishedRevisionByEntryIdRepository.ts | 4 +- .../abstractions.ts | 4 +- .../GetRevisionByIdRepository.ts | 4 +- .../GetRevisionById/abstractions.ts | 6 +- .../GetRevisionsByEntryIdRepository.ts | 4 +- .../GetRevisionsByEntryId/abstractions.ts | 6 +- .../GetUniqueFieldValuesRepository.ts | 4 +- .../GetUniqueFieldValues/abstractions.ts | 6 +- .../ListEntries/ListEntriesRepository.ts | 4 +- .../contentEntry/ListEntries/abstractions.ts | 6 +- .../MoveEntry/MoveEntryRepository.ts | 4 +- .../contentEntry/MoveEntry/abstractions.ts | 6 +- .../PublishEntry/PublishEntryRepository.ts | 4 +- .../contentEntry/PublishEntry/abstractions.ts | 6 +- .../RepublishEntryRepository.ts | 4 +- .../RepublishEntry/abstractions.ts | 6 +- .../RestoreEntryFromBinRepository.ts | 4 +- .../RestoreEntryFromBin/abstractions.ts | 6 +- .../UnpublishEntryRepository.ts | 4 +- .../UnpublishEntry/abstractions.ts | 4 +- .../UpdateEntry/UpdateEntryRepository.ts | 4 +- .../contentEntry/UpdateEntry/abstractions.ts | 4 +- .../contentModel/ContentModelFeature.ts | 2 + .../CreateModel/CreateModelRepository.ts | 115 ++++++++ .../CreateModel/CreateModelUseCase.ts | 158 ++++++++++ .../contentModel/CreateModel/abstractions.ts | 61 ++++ .../contentModel/CreateModel/events.ts | 80 +++++ .../contentModel/CreateModel/feature.ts | 20 ++ .../contentModel/CreateModel/index.ts | 6 + .../contentModel/shared/abstractions.ts | 2 +- .../CreateGroup/CreateGroupRepository.ts | 4 +- .../CreateGroup/abstractions.ts | 4 +- .../DeleteGroup/DeleteGroupRepository.ts | 4 +- .../DeleteGroup/abstractions.ts | 4 +- .../GetGroup/GetGroupRepository.ts | 4 +- .../GetGroup/abstractions.ts | 4 +- .../ListGroups/ListGroupsRepository.ts | 4 +- .../ListGroups/abstractions.ts | 4 +- .../UpdateGroup/UpdateGroupRepository.ts | 4 +- .../UpdateGroup/abstractions.ts | 4 +- packages/api-headless-cms/src/types/model.ts | 11 +- packages/api-headless-cms/src/types/types.ts | 20 -- 73 files changed, 744 insertions(+), 411 deletions(-) create mode 100644 packages/api-headless-cms/src/domain/contentModel/ensureTypeTag.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModel/index.ts diff --git a/ai-context/backend-developer-guide.md b/ai-context/backend-developer-guide.md index fc3db741ab7..157126fbbbf 100644 --- a/ai-context/backend-developer-guide.md +++ b/ai-context/backend-developer-guide.md @@ -112,6 +112,7 @@ You MUST ALWAYS use `createAbstraction` instead of `new Abstraction`. **Use case implementation** **CRITICAL RULES:** + 1. Use case class MUST implement the abstraction's `.Interface` type 2. Use case method return types MUST use the abstraction's `.Error` namespace type 3. Constructor parameters MUST use `.Interface` types from their abstractions @@ -143,7 +144,10 @@ export class UpdateUserUseCase implements UseCaseAbstraction.Interface { } // Return type MUST use the abstraction's .Error namespace type - async execute(id: string, data: Record): Promise> { + async execute( + id: string, + data: Record + ): Promise> { // Implementation goes here const result = await this.repository.update(id, data); if (result.isFail()) { @@ -238,43 +242,43 @@ Create a `errors.ts` file in your feature with domain-specific errors. If using import { BaseError } from "@webiny/feature/api"; // Wrap storage/infrastructure errors -export class ApiKeyStorageError extends BaseError { - override readonly code = "API_KEY_STORAGE_ERROR" as const; - - constructor(error: Error) { - super({ - message: error.message, - data: {} - }); - } +export class ApiKeyPersistenceError extends BaseError { + override readonly code = "API_KEY_STORAGE_ERROR" as const; + + constructor(error: Error) { + super({ + message: error.message, + data: {} + }); + } } // Domain-specific not found error export class ApiKeyNotFoundError extends BaseError { - override readonly code = "API_KEY_NOT_FOUND" as const; + override readonly code = "API_KEY_NOT_FOUND" as const; - constructor() { - super({ - message: `API key was not found!`, - data: {} - }); - } + constructor() { + super({ + message: `API key was not found!`, + data: {} + }); + } } // Authorization error with optional message type NotAuthorizedErrorData = { - message?: string; + message?: string; }; export class NotAuthorizedError extends BaseError { - override readonly code = "NOT_AUTHORIZED" as const; + override readonly code = "NOT_AUTHORIZED" as const; - constructor(data: NotAuthorizedErrorData = {}) { - super({ - message: data.message || "Not authorized to perform this action", - data - }); - } + constructor(data: NotAuthorizedErrorData = {}) { + super({ + message: data.message || "Not authorized to perform this action", + data + }); + } } ``` @@ -286,11 +290,11 @@ Define error unions in your abstraction files to provide type safety: // features/apiKeys/shared/abstractions.ts import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { ApiKeyNotFoundError, ApiKeyStorageError } from "./errors.js"; +import { ApiKeyNotFoundError, ApiKeyPersistenceError } from "./errors.js"; // Define possible errors for this repository export interface IApiKeysRepositoryErrors { - base: ApiKeyNotFoundError | ApiKeyStorageError; + base: ApiKeyNotFoundError | ApiKeyPersistenceError; } // Create error union type @@ -298,15 +302,15 @@ type RepositoryError = IApiKeysRepositoryErrors[keyof IApiKeysRepositoryErrors]; // Use in interface export interface IApiKeysRepository { - get(id: string): Promise>; - create(data: ApiKey): Promise>; + get(id: string): Promise>; + create(data: ApiKey): Promise>; } export const ApiKeysRepository = createAbstraction("ApiKeysRepository"); export namespace ApiKeysRepository { - export type Interface = IApiKeysRepository; - export type Error = RepositoryError; // Export for consumers + export type Interface = IApiKeysRepository; + export type Error = RepositoryError; // Export for consumers } ``` @@ -316,28 +320,29 @@ export namespace ApiKeysRepository { // features/apiKeys/shared/ApiKeysRepository.ts import { Result } from "@webiny/feature/api"; import { ApiKeysRepository as RepositoryAbstraction } from "./abstractions.js"; -import { ApiKeyNotFoundError, ApiKeyStorageError } from "./errors.js"; +import { ApiKeyNotFoundError, ApiKeyPersistenceError } from "./errors.js"; class ApiKeysRepositoryImpl implements RepositoryAbstraction.Interface { - async get(id: string): Promise> { - try { - const apiKey = await this.storageOperations.getApiKey({ id }); - if (apiKey) { - return Result.ok(apiKey); - } - // Return domain-specific error - return Result.fail(new ApiKeyNotFoundError()); - } catch (error) { - // Wrap infrastructure errors - return Result.fail(new ApiKeyStorageError(error as Error)); - } + async get(id: string): Promise> { + try { + const apiKey = await this.storageOperations.getApiKey({ id }); + if (apiKey) { + return Result.ok(apiKey); + } + // Return domain-specific error + return Result.fail(new ApiKeyNotFoundError()); + } catch (error) { + // Wrap infrastructure errors + return Result.fail(new ApiKeyPersistenceError(error as Error)); } + } } ``` ### Error Handling Benefits This pattern provides: + - **Type safety** - Consumers know exactly which errors can occur - **Domain context** - Specific error codes and messages - **Consistent wrapping** - Infrastructure errors wrapped in domain errors @@ -348,6 +353,7 @@ This pattern provides: **CRITICAL:** Use cases MUST extend repository errors with their own use-case-specific errors. Every use case abstraction must define: + 1. An extendable error interface for use-case-specific errors 2. A union type combining use-case errors with repository errors 3. An exported error type in the namespace @@ -361,28 +367,29 @@ import { NotAuthorizedError, ApiKeyValidationError } from "../shared/errors.js"; // 1. Define extendable interface for use-case-specific errors export interface ICreateApiKeyErrors { - notAuthorized: NotAuthorizedError; - validation: ApiKeyValidationError; + notAuthorized: NotAuthorizedError; + validation: ApiKeyValidationError; } // 2. Create union of use-case errors + repository errors -type CreateApiKeyError = ICreateApiKeyErrors[keyof ICreateApiKeyErrors] | ApiKeysRepository.Error; +type CreateApiKeyError = ICreateApiKeyErrors[keyof ICreateApiKeyErrors]; // 3. Use in interface export interface ICreateApiKey { - execute(input: CreateApiKeyInput): Promise>; + execute(input: CreateApiKeyInput): Promise>; } export const CreateApiKey = createAbstraction("CreateApiKey"); // 4. Export error type in namespace export namespace CreateApiKey { - export type Interface = ICreateApiKey; - export type Error = CreateApiKeyError; // Consumers can use CreateApiKey.Error + export type Interface = ICreateApiKey; + export type Error = CreateApiKeyError; // Consumers can use CreateApiKey.Error } ``` This pattern ensures: + - All repository errors (storage, not found, etc.) are automatically included - Use case can add its own specific errors (validation, authorization, business rules) - Type safety throughout the error handling chain @@ -397,30 +404,30 @@ import { ApiKeysRepository } from "../shared/abstractions.js"; import { NotAuthorizedError, ApiKeyValidationError } from "../shared/errors.js"; class CreateApiKeyUseCaseImpl implements CreateApiKey.Interface { - constructor( - private identityContext: IdentityContext.Interface, - private repository: ApiKeysRepository.Interface - ) {} - - async execute(input: CreateApiKeyInput): Promise> { - // Use-case specific error - if (!hasPermission) { - return Result.fail(new NotAuthorizedError()); - } - - // Validation error - if (!validation.success) { - return Result.fail(new ApiKeyValidationError(validation.error.message)); - } - - // Repository errors are automatically included in CreateApiKey.Error - const result = await this.repository.create(apiKey); - if (result.isFail()) { - return Result.fail(result.error); // Could be ApiKeyStorageError - } - - return Result.ok(result.value); + constructor( + private identityContext: IdentityContext.Interface, + private repository: ApiKeysRepository.Interface + ) {} + + async execute(input: CreateApiKeyInput): Promise> { + // Use-case specific error + if (!hasPermission) { + return Result.fail(new NotAuthorizedError()); + } + + // Validation error + if (!validation.success) { + return Result.fail(new ApiKeyValidationError(validation.error.message)); + } + + // Repository errors are domain errors, so it is safe to return them as-is. + const result = await this.repository.create(apiKey); + if (result.isFail()) { + return Result.fail(result.error); // Could be ApiKeyPersistenceError } + + return Result.ok(result.value); + } } ``` @@ -437,7 +444,7 @@ return Result.fail(new DomainSpecificError()); // Check result if (result.isFail()) { - return Result.fail(result.error); + return Result.fail(result.error); } // Access value @@ -453,20 +460,21 @@ Never use `result.isError()`, `result.getError()`, or `result.getValue()` - thes ```typescript // shared/errors.ts export class ApiKeyValidationError extends BaseError<{ message: string }> { - override readonly code = "API_KEY_VALIDATION_ERROR" as const; - - constructor(message: string) { - super({ - message, - data: { message } - }); - } + // E.g. Cms/Model/ValidationError + override readonly code = "{App}/{Domain}/ValidationError" as const; + + constructor(message: string) { + super({ + message, + data: { message } + }); + } } // In use case const validation = schema.safeParse(input); if (!validation.success) { - return Result.fail(new ApiKeyValidationError(validation.error.errors[0].message)); + return Result.fail(new ApiKeyValidationError(validation.error.errors[0].message)); } ``` @@ -489,38 +497,36 @@ import type { TeamBeforeCreatePayload, TeamAfterCreatePayload } from "./abstract // Before event with handler abstraction export class TeamBeforeCreateEvent extends DomainEvent { - eventType = "team.beforeCreate" as const; + eventType = "team.beforeCreate" as const; - getHandlerAbstraction() { - return TeamBeforeCreateHandler; - } + getHandlerAbstraction() { + return TeamBeforeCreateHandler; + } } -export const TeamBeforeCreateHandler = createAbstraction>( - "TeamBeforeCreateHandler" -); +export const TeamBeforeCreateHandler = + createAbstraction>("TeamBeforeCreateHandler"); export namespace TeamBeforeCreateHandler { - export type Interface = IEventHandler; - export type Event = TeamBeforeCreateEvent; + export type Interface = IEventHandler; + export type Event = TeamBeforeCreateEvent; } // After event with handler abstraction export class TeamAfterCreateEvent extends DomainEvent { - eventType = "team.afterCreate" as const; + eventType = "team.afterCreate" as const; - getHandlerAbstraction() { - return TeamAfterCreateHandler; - } + getHandlerAbstraction() { + return TeamAfterCreateHandler; + } } -export const TeamAfterCreateHandler = createAbstraction>( - "TeamAfterCreateHandler" -); +export const TeamAfterCreateHandler = + createAbstraction>("TeamAfterCreateHandler"); export namespace TeamAfterCreateHandler { - export type Interface = IEventHandler; - export type Event = TeamAfterCreateEvent; + export type Interface = IEventHandler; + export type Event = TeamAfterCreateEvent; } ``` @@ -531,13 +537,13 @@ Define event payloads in the abstraction file: ```typescript // features/teams/CreateTeam/abstractions.ts export interface TeamBeforeCreatePayload { - team: Team; - input: CreateTeamInput; + team: Team; + input: CreateTeamInput; } export interface TeamAfterCreatePayload { - team: Team; - input: CreateTeamInput; + team: Team; + input: CreateTeamInput; } ``` @@ -551,42 +557,40 @@ import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { TeamBeforeCreateEvent, TeamAfterCreateEvent } from "./events.js"; class CreateTeamUseCaseImpl implements CreateTeam.Interface { - constructor( - private eventPublisher: EventPublisher.Interface, - private repository: TeamsRepository.Interface - ) {} - - async execute(input: CreateTeamInput): Promise> { - const team = createTeamFromInput(input); - - // Publish before event - await this.eventPublisher.publish( - new TeamBeforeCreateEvent({ team, input }) - ); - - const result = await this.repository.create(team); - if (result.isFail()) { - return Result.fail(result.error); - } - - // Publish after event - await this.eventPublisher.publish( - new TeamAfterCreateEvent({ team, input }) - ); - - return Result.ok(team); + constructor( + private eventPublisher: EventPublisher.Interface, + private repository: TeamsRepository.Interface + ) {} + + async execute(input: CreateTeamInput): Promise> { + const team = createTeamFromInput(input); + + // Publish before event + await this.eventPublisher.publish(new TeamBeforeCreateEvent({ team, input })); + + const result = await this.repository.create(team); + if (result.isFail()) { + return Result.fail(result.error); } + + // Publish after event + await this.eventPublisher.publish(new TeamAfterCreateEvent({ team, input })); + + return Result.ok(team); + } } ``` ### Event Naming Convention Follow these naming conventions: + - Event types use `entityName.action` format (e.g., `"team.beforeCreate"`, `"team.afterUpdate"`) - Handler abstractions use `EntityActionHandler` format (e.g., `TeamBeforeCreateHandler`) - Event classes use `EntityActionEvent` format (e.g., `TeamBeforeCreateEvent`) **Critical Rules:** + 1. **ALWAYS create handler abstractions** - Every event must have a `createAbstraction` for its handler 2. **Use `eventType` property** - Not `static type` 3. **Implement `getHandlerAbstraction()`** - This method returns the handler abstraction @@ -594,24 +598,26 @@ Follow these naming conventions: 5. **ALWAYS export namespace with Interface and Event types** - Every handler abstraction MUST export a namespace containing both the Interface type and the Event class **Example of correct namespace export:** + ```typescript -export const TeamBeforeCreateHandler = createAbstraction>( - "TeamBeforeCreateHandler" -); +export const TeamBeforeCreateHandler = + createAbstraction>("TeamBeforeCreateHandler"); export namespace TeamBeforeCreateHandler { - export type Interface = IEventHandler; - export type Event = TeamBeforeCreateEvent; + export type Interface = IEventHandler; + export type Event = TeamBeforeCreateEvent; } ``` This namespace pattern enables: + - Type-safe access to handler interface via `TeamBeforeCreateHandler.Interface` - Type-safe access to event class via `TeamBeforeCreateHandler.Event` - Proper event handler registration in DI container - Clear coupling between events and their handlers This pattern enables: + - Type-safe event handling with DI - Decoupled event producers and consumers - Testable event handlers diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 0e3d2a9e9b8..bb272d42ada 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -30,11 +30,10 @@ import { createTopic } from "@webiny/pubsub"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; import { createModelCreateFromValidation, - createModelCreateValidation, createModelUpdateValidation } from "~/crud/contentModel/validation.js"; import { createZodError, removeUndefinedValues } from "@webiny/utils"; -import { assignModelDefaultFields } from "~/crud/contentModel/defaultFields.js"; +import { CreateModelUseCase } from "~/features/contentModel/CreateModel/index.js"; import { createCacheKey, createMemoryCache } from "~/utils/index.js"; import { ensureTypeTag } from "./contentModel/ensureTypeTag.js"; import { listModelsFromDatabase } from "~/crud/contentModel/listModelsFromDatabase.js"; @@ -44,7 +43,6 @@ import { CmsModelFieldToAstConverterFromPlugins, CmsModelToAstConverter } from "~/utils/contentModelAst/index.js"; -import { SingletonModelManager } from "~/modelManager/index.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; @@ -144,8 +142,7 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex return { ...model, tags: ensureTypeTag(model), - tenant: model.tenant || getTenant().id, - locale: model.locale || getLocale().code + tenant: model.tenant || getTenant().id }; }; @@ -219,29 +216,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }); }; - const getEntryManager: CmsModelContext["getEntryManager"] = async < - T extends CmsEntryValues = CmsEntryValues - >( - target: string | Pick - ): Promise> => { - const modelId = typeof target === "string" ? target : target.modelId; - if (managers.has(modelId)) { - return managers.get(modelId) as CmsModelManager; - } - const model = await getModelFromCache(modelId); - return await updateManager(context, model); - }; - - const getSingletonEntryManager = async ( - input: CmsModel | string - ) => { - const model = typeof input === "string" ? await getModel(input) : input; - - const manager = await getEntryManager(model); - - return SingletonModelManager.create(manager); - }; - /** * Create */ @@ -284,78 +258,16 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex * CRUD methods */ const createModel: CmsModelContext["createModel"] = async input => { - await accessControl.ensureCanAccessModel({ rwd: "w" }); + // Delegate to new CreateModel use case + const useCase = context.container.resolve(CreateModelUseCase); + const result = await useCase.execute(input); - const result = await createModelCreateValidation().safeParseAsync(input); - if (!result.success) { - throw createZodError(result.error); - } - /** - * We need to extract the defaultFields because it is not for the CmsModel object. - */ - const { defaultFields, ...data } = removeUndefinedValues(result.data); - if (defaultFields) { - assignModelDefaultFields(data); + if (result.isFail()) { + const code = result.error.code; + throw new WebinyError(result.error.message, result.error.code, result.error.data); } - const group = await context.cms.getGroup(data.group); - - const identity = getIdentity(); - const model: CmsModel = { - ...data, - modelId: data.modelId || "", - titleFieldId: "id", - descriptionFieldId: null, - imageFieldId: null, - locale: getLocale().code, - tenant: getTenant().id, - group: { - id: group.id, - name: group.name - }, - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), - lockedFields: [], - webinyVersion: context.WEBINY_VERSION - }; - - model.tags = ensureTypeTag(model); - - await accessControl.ensureCanAccessModel({ model, rwd: "w" }); - - try { - await onModelBeforeCreate.publish({ - input: data, - model - }); - - const createdModel = await storageOperations.models.create({ - model - }); - - clearModelsCache(); - - await updateManager(context, model); - - await onModelAfterCreate.publish({ - input: data, - model: createdModel - }); - - return createdModel; - } catch (ex) { - await onModelCreateError.publish({ - input: data, - model, - error: ex - }); - throw ex; - } + return result.value; }; const updateModel: CmsModelContext["updateModel"] = async (modelId, input) => { await accessControl.ensureCanAccessModel({ rwd: "w" }); @@ -404,7 +316,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex group, description: data.description || original.description, tenant: original.tenant || getTenant().id, - locale: original.locale || getLocale().code, webinyVersion: context.WEBINY_VERSION, savedOn: new Date().toISOString() }; @@ -450,7 +361,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex const model: CmsModel = { ...initialModel, tenant: initialModel.tenant || getTenant().id, - locale: initialModel.locale || getLocale().code, webinyVersion: context.WEBINY_VERSION }; @@ -504,8 +414,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex const data = removeUndefinedValues(result.data); - const locale = getLocale(); - /** * Use storage operations directly because we cannot get group from different locale via context methods. */ @@ -522,7 +430,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex ...original, singularApiName: data.singularApiName, pluralApiName: data.pluralApiName, - locale: locale.code, group: { id: group.id, name: group.name @@ -691,9 +598,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex return initializeModel(modelId, data); } ); - }, - getEntryManager, - getEntryManagers: () => managers, - getSingletonEntryManager + } }; }; diff --git a/packages/api-headless-cms/src/crud/contentModel/listModelsFromDatabase.ts b/packages/api-headless-cms/src/crud/contentModel/listModelsFromDatabase.ts index 4907714e68b..2a445b51d35 100644 --- a/packages/api-headless-cms/src/crud/contentModel/listModelsFromDatabase.ts +++ b/packages/api-headless-cms/src/crud/contentModel/listModelsFromDatabase.ts @@ -26,11 +26,6 @@ export const listModelsFromDatabase = async (params: Params): Promise) => { + // Let's make sure we have a `type` tag assigned. + // If `type` tag is not set, set it to a default one (`model`). + const tags = model.tags || []; + if (!tags.some(tag => tag.startsWith("type:"))) { + tags.push("type:model"); + } + + return tags; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts index ba53520cb22..0c025f19ab8 100644 --- a/packages/api-headless-cms/src/domain/contentModel/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModel/errors.ts @@ -37,8 +37,8 @@ export class ModelAlreadyExistsError extends BaseError { } } -export class ModelStorageError extends BaseError { - override readonly code = "Cms/Model/StorageError" as const; +export class ModelPersistenceError extends BaseError { + override readonly code = "Cms/Model/PersistenceError" as const; constructor(error: Error) { super({ diff --git a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts index bc4f83ebafe..b84bc58a32d 100644 --- a/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModelGroup/errors.ts @@ -21,8 +21,8 @@ export class GroupSlugTakenError extends BaseError { } } -export class GroupStorageError extends BaseError { - override readonly code = "Cms/ModelGroup/StorageError" as const; +export class GroupPersistenceError extends BaseError { + override readonly code = "Cms/ModelGroup/PersistenceError" as const; constructor(error: Error) { super({ diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts index e3d302cf37d..93af72d96ce 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { CreateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class CreateEntryRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts index 0e776365fc7..e3f17b00d10 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; -import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -39,7 +39,7 @@ export interface ICreateEntryRepository { } export interface ICreateEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = ICreateEntryRepositoryErrors[keyof ICreateEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts index 84e42d0593f..b83869fe09f 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * CreateEntryRevisionFromRepository - Handles storage operations for creating entry revisions. @@ -42,7 +42,7 @@ class CreateEntryRevisionFromRepositoryImpl implements RepositoryAbstraction.Int return Result.ok(transformedEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts index c87793abcd8..28cfc9a0814 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts @@ -7,7 +7,7 @@ import type { CreateCmsEntryOptionsInput } from "~/types/index.js"; import type { - EntryStorageError, + EntryPersistenceError, EntryValidationError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; @@ -29,7 +29,7 @@ export interface ICreateEntryRevisionFromUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = @@ -82,7 +82,7 @@ export interface ICreateEntryRevisionFromRepository { } export interface ICreateEntryRevisionFromRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts index 05c7d29b01f..3d640b3d5fe 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -19,7 +19,7 @@ class DeleteEntryRepositoryImpl implements RepositoryAbstraction.Interface { await this.storageOperations.entries.delete(model, { entry }); return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts index aff4950c356..7cd7ed75171 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { MoveEntryToBinRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; @@ -31,7 +31,7 @@ class MoveEntryToBinRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts index decefb1e3a0..1145a1a0b47 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -19,7 +19,7 @@ export interface IDeleteEntryUseCase { export interface IDeleteEntryUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IDeleteEntryUseCaseErrors[keyof IDeleteEntryUseCaseErrors]; @@ -67,7 +67,7 @@ export interface IDeleteEntryRepository { } export interface IDeleteEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IDeleteEntryRepositoryErrors[keyof IDeleteEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts index 8876c1ff338..d89a8baebbe 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/decorators/ForceDeleteDecorator.ts @@ -4,7 +4,7 @@ import { DeleteEntryUseCase } from "../abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; import { parseIdentifier } from "@webiny/utils"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * Handles force delete logic for cleanup scenarios. @@ -42,7 +42,7 @@ class ForceDeleteDecoratorImpl implements DeleteEntryUseCase.Interface { return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts index 9776c248d67..514e8daa8b1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { DeleteEntryRevisionRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; @@ -51,7 +51,7 @@ class DeleteEntryRevisionRepositoryImpl implements RepositoryAbstraction.Interfa return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts index a680f6c733a..84453660691 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -15,7 +15,7 @@ export interface IDeleteEntryRevisionUseCase { export interface IDeleteEntryRevisionUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IDeleteEntryRevisionUseCaseErrors[keyof IDeleteEntryRevisionUseCaseErrors]; @@ -66,7 +66,7 @@ export interface IDeleteEntryRevisionRepository { } export interface IDeleteEntryRevisionRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IDeleteEntryRevisionRepositoryErrors[keyof IDeleteEntryRevisionRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts index ed90e9d2ebc..6f5bf1868ac 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { DeleteMultipleEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * DeleteMultipleEntriesRepository - Handles storage operations for deleting multiple entries. @@ -25,7 +25,7 @@ class DeleteMultipleEntriesRepositoryImpl implements RepositoryAbstraction.Inter }); return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts index 7813f090f78..9878bc1151b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -16,7 +16,7 @@ export interface IDeleteMultipleEntriesUseCase { export interface IDeleteMultipleEntriesUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IDeleteMultipleEntriesUseCaseErrors[keyof IDeleteMultipleEntriesUseCaseErrors]; @@ -66,7 +66,7 @@ export interface IDeleteMultipleEntriesRepository { } export interface IDeleteMultipleEntriesRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts index 5438f6399ab..0a606deb99e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class GetEntriesByIdsRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(items as CmsEntry[]); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts index 90af4d58cf7..9e012584081 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -17,7 +17,7 @@ export interface IGetEntriesByIdsUseCase { export interface IGetEntriesByIdsUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetEntriesByIdsUseCaseErrors[keyof IGetEntriesByIdsUseCaseErrors]; @@ -42,7 +42,7 @@ export interface IGetEntriesByIdsRepository { } export interface IGetEntriesByIdsRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IGetEntriesByIdsRepositoryErrors[keyof IGetEntriesByIdsRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts index 28aeb6685cb..4527e0c90b2 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -18,7 +18,7 @@ export interface IGetEntryUseCase { export interface IGetEntryUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetEntryUseCaseErrors[keyof IGetEntryUseCaseErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts index bd496cc8c89..8729070367b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -18,7 +18,7 @@ export interface IGetEntryByIdUseCase { export interface IGetEntryByIdUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetEntryByIdUseCaseErrors[keyof IGetEntryByIdUseCaseErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts index 8765c632e3b..bc72c4a7c30 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class GetLatestEntriesByIdsRepositoryImpl implements RepositoryAbstraction.Inter return Result.ok(items as CmsEntry[]); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts index 770a595bc22..669d1aa2283 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -17,7 +17,7 @@ export interface IGetLatestEntriesByIdsUseCase { export interface IGetLatestEntriesByIdsUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetLatestEntriesByIdsUseCaseErrors[keyof IGetLatestEntriesByIdsUseCaseErrors]; @@ -42,7 +42,7 @@ export interface IGetLatestEntriesByIdsRepository { } export interface IGetLatestEntriesByIdsRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IGetLatestEntriesByIdsRepositoryErrors[keyof IGetLatestEntriesByIdsRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts index f438eac43a8..ab7f1c97e63 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class GetLatestRevisionByEntryIdRepositoryImpl implements RepositoryAbstraction. return Result.ok(transformedEntry as CmsEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts index 9550ea89ff3..c53fa4940db 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts @@ -6,7 +6,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -23,7 +23,7 @@ export interface IGetLatestRevisionByEntryIdBaseUseCase { export interface IGetLatestRevisionByEntryIdUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = @@ -92,7 +92,7 @@ export interface IGetLatestRevisionByEntryIdRepository { } export interface IGetLatestRevisionByEntryIdRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; notFound: EntryNotFoundError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts index 734a86b8690..3e35174d3c6 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/GetPreviousRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPreviousRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, @@ -37,7 +37,7 @@ class GetPreviousRevisionByEntryIdRepositoryImpl implements RepositoryAbstractio return Result.ok(transformedEntry as CmsEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts index f4262871cb1..4498904d424 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts @@ -6,7 +6,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -23,7 +23,7 @@ export interface IGetPreviousRevisionByEntryIdBaseUseCase { export interface IGetPreviousRevisionByEntryIdUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = @@ -63,7 +63,7 @@ export interface IGetPreviousRevisionByEntryIdRepository { } export interface IGetPreviousRevisionByEntryIdRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; notFound: EntryNotFoundError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts index 460099b0687..ce8374dcbba 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPublishedEntriesByIdsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class GetPublishedEntriesByIdsRepositoryImpl implements RepositoryAbstraction.In return Result.ok(items as CmsEntry[]); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts index 100dc47ba01..d23d8747b23 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -17,7 +17,7 @@ export interface IGetPublishedEntriesByIdsUseCase { export interface IGetPublishedEntriesByIdsUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetPublishedEntriesByIdsUseCaseErrors[keyof IGetPublishedEntriesByIdsUseCaseErrors]; @@ -42,7 +42,7 @@ export interface IGetPublishedEntriesByIdsRepository { } export interface IGetPublishedEntriesByIdsRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IGetPublishedEntriesByIdsRepositoryErrors[keyof IGetPublishedEntriesByIdsRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts index 740721de423..0971f982f6b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/GetPublishedRevisionByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetPublishedRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -37,7 +37,7 @@ class GetPublishedRevisionByEntryIdRepositoryImpl implements RepositoryAbstracti return Result.ok(entry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts index 6737348c8c9..8f9082beb96 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts @@ -3,7 +3,7 @@ import { Result } from "@webiny/feature/api"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import type { CmsEntryStorageOperationsGetPublishedRevisionParams } from "~/types/index.js"; -import { EntryNotFoundError, type EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedRevisionByEntryId Use Case @@ -37,7 +37,7 @@ export interface IGetPublishedRevisionByEntryIdRepository { } export interface IGetPublishedRevisionByEntryIdRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; notFound: EntryNotFoundError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts index da0db216ba4..47df61a9221 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/GetRevisionByIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetRevisionByIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -35,7 +35,7 @@ class GetRevisionByIdRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(entry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts index 98d7d368327..763ba96bba4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionById Use Case - Fetches a specific entry revision by ID. @@ -13,7 +13,7 @@ export interface IGetRevisionByIdUseCase { export interface IGetRevisionByIdUseCaseErrors { notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetRevisionByIdUseCaseErrors[keyof IGetRevisionByIdUseCaseErrors]; @@ -37,7 +37,7 @@ export interface IGetRevisionByIdRepository { export interface IGetRevisionByIdRepositoryErrors { notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IGetRevisionByIdRepositoryErrors[keyof IGetRevisionByIdRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts index 0a8786a79f0..45e513a9a98 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetRevisionsByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -34,7 +34,7 @@ class GetRevisionsByEntryIdRepositoryImpl implements RepositoryAbstraction.Inter return Result.ok(items as CmsEntry[]); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts index e49d246e872..305afa71ca5 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -17,7 +17,7 @@ export interface IGetRevisionsByEntryIdUseCase { export interface IGetRevisionsByEntryIdUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IGetRevisionsByEntryIdUseCaseErrors[keyof IGetRevisionsByEntryIdUseCaseErrors]; @@ -42,7 +42,7 @@ export interface IGetRevisionsByEntryIdRepository { } export interface IGetRevisionsByEntryIdRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IGetRevisionsByEntryIdRepositoryErrors[keyof IGetRevisionsByEntryIdRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts index 9a1bba955ce..33bdce1673b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesRepository.ts @@ -5,7 +5,7 @@ import { GetUniqueFieldValuesParams } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; class GetUniqueFieldValuesRepositoryImpl implements RepositoryAbstraction.Interface { @@ -25,7 +25,7 @@ class GetUniqueFieldValuesRepositoryImpl implements RepositoryAbstraction.Interf return Result.ok(values); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts index e3c2c4da0da..2b7588a0634 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; @@ -28,7 +28,7 @@ export interface IGetUniqueFieldValuesUseCase { export interface IGetUniqueFieldValuesUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; fieldNotSearchable: FieldNotSearchableError; invalidWhere: InvalidWhereConditionError; } @@ -56,7 +56,7 @@ export interface IGetUniqueFieldValuesRepository { } export interface IGetUniqueFieldValuesRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts index 6f9f7627fdb..42fe4887839 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { ListEntriesRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsEntryListParams, @@ -54,7 +54,7 @@ class ListEntriesRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok([items as CmsEntry[], meta]); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts index dad5e7c5184..9ec4d0ab776 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryValues, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -23,7 +23,7 @@ export interface IListEntriesUseCase { export interface IListEntriesUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IListEntriesUseCaseErrors[keyof IListEntriesUseCaseErrors]; @@ -103,7 +103,7 @@ export interface IListEntriesRepository { } export interface IListEntriesRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IListEntriesRepositoryErrors[keyof IListEntriesRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts index 44d9ab0479b..8cfb049fd87 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { MoveEntryRepository as RepositoryAbstraction } from "./abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * MoveEntryRepository - Handles storage operations for moving entries. @@ -24,7 +24,7 @@ class MoveEntryRepositoryImpl implements RepositoryAbstraction.Interface { await this.storageOperations.entries.move(model, id, folderId); return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts index edcd0ef5b37..122254b7ae4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; @@ -15,7 +15,7 @@ export interface IMoveEntryUseCase { export interface IMoveEntryUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IMoveEntryUseCaseErrors[keyof IMoveEntryUseCaseErrors]; @@ -63,7 +63,7 @@ export interface IMoveEntryRepository { } export interface IMoveEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IMoveEntryRepositoryErrors[keyof IMoveEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts index 1c9dc40a701..744943b0e2f 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * PublishEntryRepository - Handles storage operations for publishing entries. @@ -42,7 +42,7 @@ class PublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(transformedEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index 170a5160cce..c547b05d5cd 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError, EntryValidationError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; @@ -16,7 +16,7 @@ export interface IPublishEntryUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IPublishEntryUseCaseErrors[keyof IPublishEntryUseCaseErrors]; @@ -64,7 +64,7 @@ export interface IPublishEntryRepository { } export interface IPublishEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IPublishEntryRepositoryErrors[keyof IPublishEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts index 22cdacf92c6..a82676f00af 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * RepublishEntryRepository - Handles storage operations for republishing entries. @@ -49,7 +49,7 @@ class RepublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(transformedEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts index 2038c3288d1..019c1100ea3 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; @@ -16,7 +16,7 @@ export interface IRepublishEntryUseCase { export interface IRepublishEntryUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IRepublishEntryUseCaseErrors[keyof IRepublishEntryUseCaseErrors]; @@ -63,7 +63,7 @@ export interface IRepublishEntryRepository { } export interface IRepublishEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IRepublishEntryRepositoryErrors[keyof IRepublishEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts index 3710d509779..59e8d63e893 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinRepository.ts @@ -5,7 +5,7 @@ import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** * RestoreEntryFromBinRepository - Handles storage operations for restoring entries from bin. @@ -42,7 +42,7 @@ class RestoreEntryFromBinRepositoryImpl implements RepositoryAbstraction.Interfa return Result.ok(transformedEntry); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts index dc59d1dffad..386b3e3aa80 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; -import type { EntryNotFoundError, EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** @@ -15,7 +15,7 @@ export interface IRestoreEntryFromBinUseCase { export interface IRestoreEntryFromBinUseCaseErrors { notAuthorized: ContentEntryNotAuthorizedError; notFound: EntryNotFoundError; - storage: EntryStorageError; + storage: EntryPersistenceError; } type UseCaseError = IRestoreEntryFromBinUseCaseErrors[keyof IRestoreEntryFromBinUseCaseErrors]; @@ -62,7 +62,7 @@ export interface IRestoreEntryFromBinRepository { } export interface IRestoreEntryFromBinRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IRestoreEntryFromBinRepositoryErrors[keyof IRestoreEntryFromBinRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts index 1dd6101fe60..0f0c56d23ee 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { UnpublishEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -33,7 +33,7 @@ class UnpublishEntryRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(result); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts index 3c0137b4cae..7cf7a84afb8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts @@ -3,7 +3,7 @@ import { Result } from "@webiny/feature/api"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; -import type { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; @@ -41,7 +41,7 @@ export interface IUnpublishEntryRepository { } export interface IUnpublishEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IUnpublishEntryRepositoryErrors[keyof IUnpublishEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts index 02628dda4f4..ac20de1ff22 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryRepository.ts @@ -1,7 +1,7 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { UpdateEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import { EntryStorageError } from "~/domain/contentEntry/errors.js"; +import { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryToStorageTransform } from "~/legacy/abstractions.js"; @@ -32,7 +32,7 @@ class UpdateEntryRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new EntryStorageError(error as Error)); + return Result.fail(new EntryPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts index aca637f3a15..3786e2359fd 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts @@ -9,7 +9,7 @@ import type { import type { GenericRecord } from "@webiny/api/types.js"; import type { EntryNotFoundError, - EntryStorageError, + EntryPersistenceError, EntryValidationError, EntryLockedError } from "~/domain/contentEntry/errors.js"; @@ -54,7 +54,7 @@ export interface IUpdateEntryRepository { } export interface IUpdateEntryRepositoryErrors { - storage: EntryStorageError; + storage: EntryPersistenceError; } type RepositoryError = IUpdateEntryRepositoryErrors[keyof IUpdateEntryRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index bbd162df980..909cc674486 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -3,6 +3,7 @@ import { createMemoryCache } from "~/utils/index.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/PluginModelsProvider.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; +import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; export const ContentModelFeature = createFeature({ name: "ContentModel", @@ -14,5 +15,6 @@ export const ContentModelFeature = createFeature({ // Query features // Command features + CreateModelFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts new file mode 100644 index 00000000000..cd98a756cc0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts @@ -0,0 +1,115 @@ +import { Result } from "@webiny/feature/api"; +import { CreateModelRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import type { CmsModel } from "~/types/index.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; + +/** + * CreateModelRepository - Validates and persists a new model. + * + * Responsibilities: + * - Validate modelId uniqueness + * - Validate API name uniqueness (singularApiName, pluralApiName) + * - Check for plugin model conflicts + * - Persist to storage + * - Clear ModelCache after successful create + */ +class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private tenantContext: TenantContext.Interface + ) {} + + async execute(model: CmsModel): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // Validate modelId uniqueness + if (model.modelId) { + const existingById = await this.storageOperations.models.list({ + where: { + tenant: tenant.id, + modelId: model.modelId + } + }); + + if (existingById.length > 0) { + return Result.fail(new ModelSlugTakenError(model.modelId)); + } + } + + // Validate API name uniqueness + const apiNameConflict = await this.checkApiNameConflict(model, tenant.id); + if (apiNameConflict) { + return Result.fail( + new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) + ); + } + + // Check for plugin model conflicts + const pluginModels = await this.pluginModelsProvider.list(tenant.id); + const pluginModelConflict = pluginModels.find(pm => { + return ( + pm.modelId === model.modelId || + pm.singularApiName === model.singularApiName || + pm.pluralApiName === model.pluralApiName + ); + }); + + if (pluginModelConflict) { + return Result.fail( + new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) + ); + } + + // TODO: ideally, this will eventually be handled by the CmsModel domain object + model.tags = ensureTypeTag(model); + + // Persist to storage + await this.storageOperations.models.create({ model }); + + // Clear cache + this.modelCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } + + private async checkApiNameConflict(model: CmsModel, tenant: string): Promise { + // Check singular API name + const existingBySingular = await this.storageOperations.models.list({ + where: { + tenant, + singularApiName: model.singularApiName + } + }); + + if (existingBySingular.length > 0) { + return true; + } + + // Check plural API name + const existingByPlural = await this.storageOperations.models.list({ + where: { + tenant, + pluralApiName: model.pluralApiName + } + }); + + return existingByPlural.length > 0; + } +} + +export const CreateModelRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateModelRepositoryImpl, + dependencies: [ModelCache, PluginModelsProvider, StorageOperations, TenantContext] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts new file mode 100644 index 00000000000..88ed016e35e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts @@ -0,0 +1,158 @@ +import { Result } from "@webiny/feature/api"; +import { CreateModelUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CreateModelRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { ModelBeforeCreateEvent } from "./events.js"; +import { ModelAfterCreateEvent } from "./events.js"; +import { ModelCreateErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { + ModelNotAuthorizedError, + ModelPersistenceError, + ModelValidationError +} from "~/domain/contentModel/errors.js"; +import { createZodError } from "@webiny/utils"; +import { mdbid } from "@webiny/utils"; +import { removeUndefinedValues } from "@webiny/utils"; +import { createModelCreateValidation } from "~/domain/contentModel/validation.js"; +import { assignModelDefaultFields } from "~/crud/contentModel/defaultFields.js"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateInput } from "~/types/index.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; + +/** + * CreateModelUseCase - Orchestrates model creation. + * + * Responsibilities: + * - Validate input (Zod) + * - Create domain model object + * - Assign default fields if requested + * - Access control checks + * - Publish before event + * - Delegate to repository + * - Publish after event or error event + */ +class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getGroupUseCase: GetGroupUseCase.Interface, + private eventPublisher: EventPublisher.Interface, + private repository: CreateModelRepository.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute(input: CmsModelCreateInput): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessModel({ rwd: "w" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Validate input + const validationResult = await createModelCreateValidation().safeParseAsync(input); + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new ModelValidationError(zodError.message)); + } + + // Extract defaultFields flag and remove it from data + const { defaultFields, ...data } = removeUndefinedValues(validationResult.data); + + // Assign default fields if requested + if (defaultFields) { + assignModelDefaultFields(data); + } + + // Resolve group - reuse group domain errors as they're already descriptive + const groupResult = await this.getGroupUseCase.execute(input.group); + + if (groupResult.isFail()) { + + const error = groupResult.error; + if (error.code === "Cms/ModelGroup/PersistenceError") { + return Result.fail(new ModelPersistenceError(error)); + } + + return Result.fail(error); + } + + const group = groupResult.value; + + // Create the domain model object + const identity = this.identityContext.getIdentity(); + const tenant = this.tenantContext.getTenant(); + + const modelId = data.modelId || mdbid(); + const model: CmsModel = { + ...data, + modelId, + tenant: tenant.id, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + createdBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }, + webinyVersion: this.cmsContext.WEBINY_VERSION, + description: data.description || null, + group: { + id: group.id, + name: group.name + }, + // Fields and layout from data + fields: data.fields || [], + layout: data.layout || [], + // Title/description/image field IDs + titleFieldId: data.titleFieldId || "", + descriptionFieldId: data.descriptionFieldId, + imageFieldId: data.imageFieldId + }; + + // Access control check on the created model + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "w" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + // Publish before event + await this.eventPublisher.publish(new ModelBeforeCreateEvent({ model })); + + // Persist via repository + const result = await this.repository.execute(model); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new ModelCreateErrorEvent({ + input, + model, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish(new ModelAfterCreateEvent({ model })); + + return Result.ok(model); + } +} + +export const CreateModelUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateModelUseCaseImpl, + dependencies: [ + GetGroupUseCase, + EventPublisher, + CreateModelRepository, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts new file mode 100644 index 00000000000..ba7ac4a9365 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts @@ -0,0 +1,61 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateInput } from "~/types/index.js"; +import { + type ModelSlugTakenError, + ModelNotAuthorizedError, + type ModelValidationError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; +import { + type GroupNotFoundError, + type GroupNotAuthorizedError +} from "~/domain/contentModelGroup/errors.js"; + +/** + * CreateModel Use Case + */ +export interface ICreateModelUseCase { + execute(input: CmsModelCreateInput): Promise>; +} + +export interface ICreateModelUseCaseErrors { + notAuthorized: ModelNotAuthorizedError; + validation: ModelValidationError; + alreadyExists: ModelSlugTakenError; + persistence: ModelPersistenceError; + groupNotFound: GroupNotFoundError; // Reused from Group domain + groupNotAccessible: GroupNotAuthorizedError; // Reused from Group domain +} + +type UseCaseError = ICreateModelUseCaseErrors[keyof ICreateModelUseCaseErrors]; + +export const CreateModelUseCase = createAbstraction("CreateModelUseCase"); + +export namespace CreateModelUseCase { + export type Interface = ICreateModelUseCase; + export type Error = UseCaseError; +} + +/** + * CreateModelRepository - Persists a new model to storage. + */ +export interface ICreateModelRepository { + execute(model: CmsModel): Promise>; +} + +export interface ICreateModelRepositoryErrors { + alreadyExists: ModelSlugTakenError; + persistence: ModelPersistenceError; +} + +type RepositoryError = ICreateModelRepositoryErrors[keyof ICreateModelRepositoryErrors]; + +export const CreateModelRepository = + createAbstraction("CreateModelRepository"); + +export namespace CreateModelRepository { + export type Interface = ICreateModelRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts new file mode 100644 index 00000000000..a788b9297f4 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts @@ -0,0 +1,80 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface ModelBeforeCreatePayload { + model: CmsModel; +} + +export interface ModelAfterCreatePayload { + model: CmsModel; +} + +export interface ModelCreateErrorPayload { + input: CmsModelCreateInput; + model: CmsModel; + error: Error; +} + +/** + * ModelBeforeCreateEvent - Published before creating a model + */ +export class ModelBeforeCreateEvent extends DomainEvent { + eventType = "Cms/Model/BeforeCreate" as const; + + getHandlerAbstraction() { + return ModelBeforeCreateHandler; + } +} + +export const ModelBeforeCreateHandler = createAbstraction>( + "ModelBeforeCreateHandler" +); + +export namespace ModelBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = ModelBeforeCreateEvent; +} + +/** + * ModelAfterCreateEvent - Published after creating a model + */ +export class ModelAfterCreateEvent extends DomainEvent { + eventType = "Cms/Model/AfterCreate" as const; + + getHandlerAbstraction() { + return ModelAfterCreateHandler; + } +} + +export const ModelAfterCreateHandler = + createAbstraction>("ModelAfterCreateHandler"); + +export namespace ModelAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = ModelAfterCreateEvent; +} + +/** + * ModelCreateErrorEvent - Published when create fails + */ +export class ModelCreateErrorEvent extends DomainEvent { + eventType = "Cms/Model/CreateError" as const; + + getHandlerAbstraction() { + return ModelCreateErrorHandler; + } +} + +export const ModelCreateErrorHandler = + createAbstraction>("ModelCreateErrorHandler"); + +export namespace ModelCreateErrorHandler { + export type Interface = IEventHandler; + export type Event = ModelCreateErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts new file mode 100644 index 00000000000..931b96ea063 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateModelUseCase } from "./CreateModelUseCase.js"; +import { CreateModelRepository } from "./CreateModelRepository.js"; + +/** + * CreateModel Feature + * + * Provides functionality for creating new content models. + * Includes validation, API name uniqueness checks, and plugin conflict checks. + */ +export const CreateModelFeature = createFeature({ + name: "CreateModel", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(CreateModelUseCase); + + // Register repository in singleton scope (shared instance) + container.register(CreateModelRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/index.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/index.ts new file mode 100644 index 00000000000..80149bba9e2 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/index.ts @@ -0,0 +1,6 @@ +export { CreateModelUseCase } from "./abstractions.js"; +export { + ModelBeforeCreateHandler, + ModelAfterCreateHandler, + ModelCreateErrorHandler +} from "./events.js"; diff --git a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index 61b40a644e0..e0fd8b368b6 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -6,7 +6,7 @@ import type { ICache } from "~/utils/caching/types.js"; * PluginModelsProvider provides access to plugin-defined (code) models. */ export interface IPluginModelsProvider { - list(tenant: string, locale: string): Promise; + list(tenant: string): Promise; } export const PluginModelsProvider = diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts index f0ab42c2089..182139b13de 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts @@ -4,7 +4,7 @@ import { CreateGroupRepository as RepositoryAbstraction } from "./abstractions.j import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; import { GroupSlugTakenError } from "~/domain/contentModelGroup/errors.js"; -import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -69,7 +69,7 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); + return Result.fail(new GroupPersistenceError(error as Error)); } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts index cb00be65af4..c8fb04738b9 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/abstractions.ts @@ -7,7 +7,7 @@ import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; -import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; /** * CreateGroup Use Case @@ -40,7 +40,7 @@ export interface ICreateGroupRepository { export interface ICreateGroupRepositoryErrors { alreadyExists: GroupSlugTakenError; - storage: GroupStorageError; + storage: GroupPersistenceError; } type RepositoryError = ICreateGroupRepositoryErrors[keyof ICreateGroupRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts index 9fee5644810..1cf0531a3da 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/DeleteGroupRepository.ts @@ -5,7 +5,7 @@ import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js" import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; import { GroupCannotDeleteCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; import { GroupHasModelsError } from "~/domain/contentModelGroup/errors.js"; -import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsGroup } from "~/types/index.js"; @@ -55,7 +55,7 @@ class DeleteGroupRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); + return Result.fail(new GroupPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts index 89426febd00..a47ac68b2ce 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/abstractions.ts @@ -3,7 +3,7 @@ import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import type { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; import type { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; -import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import type { GroupCannotDeleteCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; import type { GroupHasModelsError } from "~/domain/contentModelGroup/errors.js"; @@ -39,7 +39,7 @@ export interface IDeleteGroupRepository { export interface IDeleteGroupRepositoryErrors { cannotDelete: GroupCannotDeleteCodeDefinedError; hasModels: GroupHasModelsError; - storage: GroupStorageError; + storage: GroupPersistenceError; } type RepositoryError = IDeleteGroupRepositoryErrors[keyof IDeleteGroupRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts index 699be35e024..6024d5635ed 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/GetGroupRepository.ts @@ -6,7 +6,7 @@ import { GetGroupRepository as RepositoryAbstraction } from "./abstractions.js"; import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; import { GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; -import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -50,7 +50,7 @@ class GetGroupRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(group); } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); + return Result.fail(new GroupPersistenceError(error as Error)); } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts index 5185ccd2836..d5cefb3d550 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; -import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; /** * GetGroup Use Case @@ -35,7 +35,7 @@ export interface IGetGroupRepository { export interface IGetGroupRepositoryErrors { notFound: GroupNotFoundError; - storage: GroupStorageError; + storage: GroupPersistenceError; } type RepositoryError = IGetGroupRepositoryErrors[keyof IGetGroupRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts index 4c640205102..ac0f8a2b5a7 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { ListGroupsRepository as RepositoryAbstraction } from "./abstractions.js"; import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; -import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; @@ -43,7 +43,7 @@ class ListGroupsRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(groups); } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); + return Result.fail(new GroupPersistenceError(error as Error)); } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts index 82b0ee38199..44ad41f635f 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import { GroupNotAuthorizedError } from "~/domain/contentModelGroup/errors.js"; -import { type GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { type GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; /** * ListGroups Use Case @@ -33,7 +33,7 @@ export interface IListGroupsRepository { } export interface IListGroupsRepositoryErrors { - storage: GroupStorageError; + storage: GroupPersistenceError; } type RepositoryError = IListGroupsRepositoryErrors[keyof IListGroupsRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts index 2ab111667e7..f9f8c15d51c 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupRepository.ts @@ -4,7 +4,7 @@ import { UpdateGroupRepository as RepositoryAbstraction } from "./abstractions.j import { GroupCache } from "~/features/contentModelGroup/shared/abstractions.js"; import { PluginGroupsProvider } from "~/features/contentModelGroup/shared/abstractions.js"; import { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; -import { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import type { CmsGroup } from "~/types/index.js"; @@ -41,7 +41,7 @@ class UpdateGroupRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { - return Result.fail(new GroupStorageError(error as Error)); + return Result.fail(new GroupPersistenceError(error as Error)); } } } diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts index c512214db10..78259b1cfe6 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts @@ -4,7 +4,7 @@ import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupUpdateInput } from "~/types/index.js"; import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; -import type { GroupStorageError } from "~/domain/contentModelGroup/errors.js"; +import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import type { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; /** @@ -39,7 +39,7 @@ export interface IUpdateGroupRepository { export interface IUpdateGroupRepositoryErrors { cannotUpdate: GroupCannotUpdateCodeDefinedError; - storage: GroupStorageError; + storage: GroupPersistenceError; } type RepositoryError = IUpdateGroupRepositoryErrors[keyof IUpdateGroupRepositoryErrors]; diff --git a/packages/api-headless-cms/src/types/model.ts b/packages/api-headless-cms/src/types/model.ts index 242d4cb49a3..790a5bcf01e 100644 --- a/packages/api-headless-cms/src/types/model.ts +++ b/packages/api-headless-cms/src/types/model.ts @@ -39,10 +39,6 @@ export interface CmsModel { * Model tenant. */ tenant: string; - /** - * Locale this model belongs to. - */ - locale: string; /** * Cms Group reference object. */ @@ -199,9 +195,4 @@ export interface CmsModelCreateInput { * @category GraphQL params * @category CmsModel */ -export interface CmsModelCreateFromInput extends CmsModelCreateInput { - /** - * Locale into which we want to clone the model into. - */ - locale?: string; -} +export interface CmsModelCreateFromInput extends CmsModelCreateInput {} diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 84f591e7876..0ccd032331f 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -18,7 +18,6 @@ import type { CmsModelField, CmsModelFieldValidation, CmsModelUpdateInput } from import type { CmsModel, CmsModelCreateFromInput, CmsModelCreateInput } from "./model.js"; import type { CmsGroup } from "./modelGroup.js"; import type { CmsIdentity } from "./identity.js"; -import type { ISingletonModelManager } from "~/modelManager/index.js"; import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; @@ -862,25 +861,6 @@ export interface CmsModelContext { * Primary idea behind this is creating the index, for the code models, in the ES. */ initializeModel(modelId: string, data: Record): Promise; - /** - * Get an instance of CmsModelManager for given content modelId. - * - * @see CmsModelManager - */ - getEntryManager( - model: CmsModel | string - ): Promise>; - /** - * A model manager for a model which has a single entry. - */ - getSingletonEntryManager( - model: CmsModel | string - ): Promise>; - /** - * Get all content model managers mapped by modelId. - * @see CmsModelManager - */ - getEntryManagers(): Map; /** * Clear all the model caches. */ From 6ed03d3379b8dc3842d6f22d8a7aa458be7a57dd Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 16 Nov 2025 21:14:11 +0100 Subject: [PATCH 23/71] wip: migrate content model crud --- .../src/crud/contentModel.crud.ts | 64 +++------- .../contentModel/ContentModelFeature.ts | 4 + .../GetModel/GetModelRepository.ts | 113 ++++++++++++++++++ .../contentModel/GetModel/GetModelUseCase.ts | 53 ++++++++ .../contentModel/GetModel/abstractions.ts | 51 ++++++++ .../features/contentModel/GetModel/feature.ts | 20 ++++ .../features/contentModel/GetModel/index.ts | 1 + .../ListModels/ListModelsRepository.ts | 110 +++++++++++++++++ .../ListModels/ListModelsUseCase.ts | 45 +++++++ .../contentModel/ListModels/abstractions.ts | 50 ++++++++ .../contentModel/ListModels/feature.ts | 21 ++++ .../features/contentModel/ListModels/index.ts | 1 + .../contentModel/shared/abstractions.ts | 2 +- 13 files changed, 485 insertions(+), 50 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/GetModel/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/GetModel/index.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ListModels/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ListModels/index.ts diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index bb272d42ada..5a40af0cca3 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -34,6 +34,8 @@ import { } from "~/crud/contentModel/validation.js"; import { createZodError, removeUndefinedValues } from "@webiny/utils"; import { CreateModelUseCase } from "~/features/contentModel/CreateModel/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; import { createCacheKey, createMemoryCache } from "~/utils/index.js"; import { ensureTypeTag } from "./contentModel/ensureTypeTag.js"; import { listModelsFromDatabase } from "~/crud/contentModel/listModelsFromDatabase.js"; @@ -154,65 +156,29 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex */ const listModels = async (input?: ICmsModelListParams) => { return context.benchmark.measure("headlessCms.crud.models.listModels", async () => { - /** - * Maybe we can cache based on permissions, not the identity id? - * - * TODO: @adrian please check if possible. - */ - const tenant = getTenant().id; - const locale = getLocale().code; - let pluginModels = await listPluginModels(tenant, locale); - const dbCacheKey = createCacheKey({ - tenant, - locale - }); - const databaseModels = await listDatabaseModelsCache.getOrSet(dbCacheKey, async () => { - return await listModelsFromDatabase(params); - }); + // Delegate to new ListModels use case + const useCase = context.container.resolve(ListModelsUseCase); + const result = await useCase.execute(input); - const filteredCacheKey = createCacheKey({ - dbCacheKey: dbCacheKey.get(), - identity: context.security.isAuthorizationEnabled() ? getIdentity()?.id : undefined - }); - - let filteredModels = await listFilteredModelsCache.getOrSet( - filteredCacheKey, - async () => { - return filterAsync(databaseModels, async model => { - return accessControl.canAccessModel({ model }); - }); - } - ); - /** - * Do we need to hide private models? - */ - if (input?.includePrivate === false) { - filteredModels = filteredModels.filter(model => !model.isPrivate); - pluginModels = pluginModels.filter(model => !model.isPrivate); - } - /** - * Do we need to hide plugin models? - */ - if (input?.includePlugins === false) { - return filteredModels; + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } - return filteredModels.concat(pluginModels); + return result.value; }); }; const getModel = async (modelId: string): Promise => { return context.benchmark.measure("headlessCms.crud.models.getModel", async () => { - const model = await context.security.withoutAuthorization(async () => { - return await getModelFromCache(modelId); - }); - if (!model) { - throw new NotFoundError(`Content model "${modelId}" was not found!`); - } + // Delegate to new GetModel use case + const useCase = context.container.resolve(GetModelUseCase); + const result = await useCase.execute(modelId); - await accessControl.ensureCanAccessModel({ model }); + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); + } - return model; + return result.value; }); }; diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index 909cc674486..e6bca573e76 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -4,6 +4,8 @@ import { PluginModelsProvider } from "~/features/contentModel/shared/PluginModel import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; +import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; +import { ListModelsFeature } from "~/features/contentModel/ListModels/feature.js"; export const ContentModelFeature = createFeature({ name: "ContentModel", @@ -13,6 +15,8 @@ export const ContentModelFeature = createFeature({ container.register(ModelToAstConverter); // Query features + GetModelFeature.register(container); + ListModelsFeature.register(container); // Command features CreateModelFeature.register(container); diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts new file mode 100644 index 00000000000..3446879dde9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts @@ -0,0 +1,113 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { Result } from "@webiny/feature/api"; +import { GetModelRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ModelNotFoundError } from "~/domain/contentModel/errors.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; + +/** + * GetModelRepository - Fetches a single model by ID. + * + * Responsibilities: + * - Create cache keys based on tenant + locale + identity + * - Provide data loader functions to ModelCache + * - Fetch from plugin models + database models + * - Apply access control filtering + * - Ensure type tags + * - Return the model or NotFoundError + */ +class GetModelRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute(modelId: string): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + const locale = this.cmsContext.getLocale(); + + // Fetch all models (plugin + database) with access control filtering + const models = await this.fetchAllModels(tenant.id, locale.code); + + const model = models.find(m => m.modelId === modelId); + + if (!model) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + return Result.ok(model); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } + + private async fetchAllModels(tenant: string, locale: string): Promise { + // 1. Fetch plugin models (with caching and access control) + const pluginModels = await this.pluginModelsProvider.list(tenant); + + // 2. Fetch database models (with caching) + const dbCacheKey = createCacheKey({ tenant, locale }); + const databaseModels = await this.modelCache.getOrSet(dbCacheKey, async () => { + return await this.storageOperations.models.list({ + where: { tenant, locale } + }); + }); + + // 3. Apply access control to database models (with caching) + const filteredCacheKey = createCacheKey({ + dbCacheKey: dbCacheKey.get(), + identity: this.cmsContext.security.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined + }); + + const filteredDatabaseModels = await this.modelCache.getOrSet( + filteredCacheKey, + async () => { + return filterAsync(databaseModels, async (model?: CmsModel) => { + if (!model) { + return false; + } + return this.accessControl.canAccessModel({ model }); + }); + } + ); + + // 4. Ensure type tags on database models + const taggedDatabaseModels = filteredDatabaseModels.map(model => { + model.tags = ensureTypeTag(model); + return model; + }); + + // 5. Merge plugin + database models + return [...pluginModels, ...taggedDatabaseModels]; + } +} + +export const GetModelRepository = RepositoryAbstraction.createImplementation({ + implementation: GetModelRepositoryImpl, + dependencies: [ + ModelCache, + PluginModelsProvider, + StorageOperations, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts new file mode 100644 index 00000000000..9dcd79d420c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { GetModelUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetModelRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; +import { ModelNotAuthorizedError } from "~/domain/contentModel/errors.js"; + +/** + * GetModelUseCase - Retrieves a single content model by ID. + * + * Responsibilities: + * - Apply initial access control check + * - Delegate to repository (which uses ModelCache for plugin + DB models) + * - Apply model-specific access control check + * - Return the model or appropriate error + */ +class GetModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: GetModelRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(modelId: string): Promise> { + // Initial access control check (no specific model yet) + const canAccess = await this.accessControl.canAccessModel({ rwd: "r" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Repository uses ModelCache to fetch all models, then filters by ID + // ModelCache handles merging plugin + database models and access control + const result = await this.repository.execute(modelId); + + if (result.isFail()) { + return result; + } + + const model = result.value; + + // Model-specific access control check + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "r" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + return Result.ok(model); + } +} + +export const GetModelUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetModelUseCaseImpl, + dependencies: [GetModelRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts new file mode 100644 index 00000000000..38d2d5dbfb5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts @@ -0,0 +1,51 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import { + ModelNotAuthorizedError, + type ModelNotFoundError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; + +/** + * GetModel Use Case + */ +export interface IGetModelUseCase { + execute(modelId: string): Promise>; +} + +export interface IGetModelUseCaseErrors { + notFound: ModelNotFoundError; + notAuthorized: ModelNotAuthorizedError; + persistence: ModelPersistenceError; +} + +type UseCaseError = IGetModelUseCaseErrors[keyof IGetModelUseCaseErrors]; + +export const GetModelUseCase = createAbstraction("GetModelUseCase"); + +export namespace GetModelUseCase { + export type Interface = IGetModelUseCase; + export type Error = UseCaseError; +} + +/** + * GetModelRepository - Fetches a single model by ID from cache. + */ +export interface IGetModelRepository { + execute(modelId: string): Promise>; +} + +export interface IGetModelRepositoryErrors { + notFound: ModelNotFoundError; + persistence: ModelPersistenceError; +} + +type RepositoryError = IGetModelRepositoryErrors[keyof IGetModelRepositoryErrors]; + +export const GetModelRepository = createAbstraction("GetModelRepository"); + +export namespace GetModelRepository { + export type Interface = IGetModelRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/feature.ts new file mode 100644 index 00000000000..2c6b710220e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetModelUseCase } from "./GetModelUseCase.js"; +import { GetModelRepository } from "./GetModelRepository.js"; + +/** + * GetModel Feature + * + * Provides functionality for retrieving a single content model by ID. + * Includes caching, plugin model support, and access control filtering. + */ +export const GetModelFeature = createFeature({ + name: "GetModel", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(GetModelUseCase); + + // Register repository in singleton scope (shared instance) + container.register(GetModelRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/index.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/index.ts new file mode 100644 index 00000000000..24b5820b8c8 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/index.ts @@ -0,0 +1 @@ +export { GetModelUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts new file mode 100644 index 00000000000..47ad18031b5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts @@ -0,0 +1,110 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { Result } from "@webiny/feature/api"; +import { ListModelsRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import type { CmsModel } from "~/types/index.js"; +import type { ICmsModelListParams } from "~/types/index.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; + +/** + * ListModelsRepository - Fetches all models. + * + * Responsibilities: + * - Create cache keys based on tenant + identity + * - Provide data loader functions to ModelCache + * - Fetch from plugin models + database models + * - Apply access control filtering + * - Apply includePrivate and includePlugins filters + * - Ensure type tags + * - Return all accessible models + */ +class ListModelsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute( + params?: ICmsModelListParams + ): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // Default params + const includePrivate = params?.includePrivate !== false; // defaults to true + const includePlugins = params?.includePlugins !== false; // defaults to true + + // Fetch all models (plugin + database) with access control filtering + let models = await this.fetchAllModels(tenant.id, includePlugins); + + // Filter out private models if requested + if (!includePrivate) { + models = models.filter(model => model.isPrivate !== true); + } + + return Result.ok(models); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } + + private async fetchAllModels(tenant: string, includePlugins: boolean): Promise { + // 1. Fetch plugin models (with caching and access control) if requested + const pluginModels = includePlugins ? await this.pluginModelsProvider.list(tenant) : []; + + // 2. Fetch database models (with caching) + const dbCacheKey = createCacheKey({ tenant }); + const databaseModels = await this.modelCache.getOrSet(dbCacheKey, () => { + return this.storageOperations.models.list({ where: { tenant } }); + }); + + // 3. Apply access control to database models (with caching) + const filteredCacheKey = createCacheKey({ + dbCacheKey: dbCacheKey.get(), + identity: this.identityContext.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined + }); + + const filteredDatabaseModels = await this.modelCache.getOrSet(filteredCacheKey, () => { + return filterAsync(databaseModels, async (model: CmsModel) => { + if (!model) { + return false; + } + return this.accessControl.canAccessModel({ model }); + }); + }); + + // 4. Ensure type tags on database models + const taggedDatabaseModels = filteredDatabaseModels.map(model => { + model.tags = ensureTypeTag(model); + return model; + }); + + // 5. Merge plugin + database models + return [...pluginModels, ...taggedDatabaseModels]; + } +} + +export const ListModelsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListModelsRepositoryImpl, + dependencies: [ + ModelCache, + PluginModelsProvider, + StorageOperations, + AccessControl, + TenantContext, + IdentityContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts new file mode 100644 index 00000000000..df207f0b8c3 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { ListModelsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ListModelsRepository } from "./abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; +import type { ICmsModelListParams } from "~/types/index.js"; +import { ModelNotAuthorizedError } from "~/domain/contentModel/errors.js"; + +/** + * ListModelsUseCase - Retrieves all content models. + * + * Responsibilities: + * - Apply initial access control check + * - Delegate to repository (which uses ModelCache for plugin + DB models) + * - Return all accessible models + */ +class ListModelsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: ListModelsRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(params?: ICmsModelListParams): Promise> { + // Initial access control check (no specific model yet) + const canAccess = await this.accessControl.canAccessModel({ rwd: "r" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Repository uses ModelCache to fetch all models + // ModelCache handles merging plugin + database models and access control + const result = await this.repository.execute(params); + + if (result.isFail()) { + return result; + } + + return Result.ok(result.value); + } +} + +export const ListModelsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListModelsUseCaseImpl, + dependencies: [ListModelsRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts new file mode 100644 index 00000000000..45484ab8a4e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts @@ -0,0 +1,50 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import type { ICmsModelListParams } from "~/types/index.js"; +import { + ModelNotAuthorizedError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; + +/** + * ListModels Use Case + */ +export interface IListModelsUseCase { + execute(params?: ICmsModelListParams): Promise>; +} + +export interface IListModelsUseCaseErrors { + notAuthorized: ModelNotAuthorizedError; + persistence: ModelPersistenceError; +} + +type UseCaseError = IListModelsUseCaseErrors[keyof IListModelsUseCaseErrors]; + +export const ListModelsUseCase = createAbstraction("ListModelsUseCase"); + +export namespace ListModelsUseCase { + export type Interface = IListModelsUseCase; + export type Error = UseCaseError; +} + +/** + * ListModelsRepository - Fetches all models from cache. + */ +export interface IListModelsRepository { + execute(params?: ICmsModelListParams): Promise>; +} + +export interface IListModelsRepositoryErrors { + persistence: ModelPersistenceError; +} + +type RepositoryError = IListModelsRepositoryErrors[keyof IListModelsRepositoryErrors]; + +export const ListModelsRepository = + createAbstraction("ListModelsRepository"); + +export namespace ListModelsRepository { + export type Interface = IListModelsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/feature.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/feature.ts new file mode 100644 index 00000000000..1ecce77c703 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/feature.ts @@ -0,0 +1,21 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListModelsUseCase } from "./ListModelsUseCase.js"; +import { ListModelsRepository } from "./ListModelsRepository.js"; + +/** + * ListModels Feature + * + * Provides functionality for retrieving all content models. + * Includes caching, plugin model support, access control filtering, + * and options to include/exclude private models and plugin models. + */ +export const ListModelsFeature = createFeature({ + name: "ListModels", + register(container) { + // Register use case in transient scope (new instance per request) + container.register(ListModelsUseCase); + + // Register repository in singleton scope (shared instance) + container.register(ListModelsRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/index.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/index.ts new file mode 100644 index 00000000000..71d8a16215a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/index.ts @@ -0,0 +1 @@ +export { ListModelsUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index e0fd8b368b6..4fd4c818469 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -19,7 +19,7 @@ export namespace PluginModelsProvider { export const ModelCache = createAbstraction>>("ModelCache"); export namespace ModelCache { - export type Interface = ICache; + export type Interface = ICache>; } /** From b2b2bb37b2f2cafbcb630457e06992de5a46f4c7 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 16 Nov 2025 22:29:15 +0100 Subject: [PATCH 24/71] wip: migrate content model crud --- packages/api-headless-cms/src/context.ts | 2 - .../src/crud/contentModel.crud.ts | 5 +- .../src/crud/contentModel/beforeCreate.ts | 151 ------- .../contentModel/createFieldStorageId.ts | 7 + .../{validation.ts => schemas.ts} | 0 .../contentModel/validation/endingAllowed.ts | 30 ++ .../validation/fields/descriptionField.ts | 29 ++ .../fields/getApplicableFieldById.ts | 13 + .../validation/fields/imageField.ts | 27 ++ .../validation/fields/titleField.ts | 31 ++ .../validation/isModelEndingAllowed.ts | 24 ++ .../contentModel/validation/modelFields.ts | 396 ++++++++++++++++++ .../domain/contentModel/validation/modelId.ts | 49 +++ .../contentModel/validation/pluralApiName.ts | 29 ++ .../validation/singularApiName.ts | 29 ++ .../contentModel/ContentModelFeature.ts | 2 + .../CreateModel/CreateModelRepository.ts | 165 +++++--- .../CreateModel/CreateModelUseCase.ts | 19 +- .../contentModel/CreateModel/abstractions.ts | 3 +- .../contentModel/CreateModel/events.ts | 1 + .../contentModel/CreateModel/feature.ts | 4 +- .../GetModel/GetModelRepository.ts | 101 +---- .../ListModels/ListModelsRepository.ts | 104 +---- .../contentModel/shared/ModelsFetcher.ts | 118 ++++++ .../contentModel/shared/abstractions.ts | 37 +- 25 files changed, 990 insertions(+), 386 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentModel/beforeCreate.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/createFieldStorageId.ts rename packages/api-headless-cms/src/domain/contentModel/{validation.ts => schemas.ts} (100%) create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/endingAllowed.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/fields/descriptionField.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/fields/getApplicableFieldById.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/fields/imageField.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/fields/titleField.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/isModelEndingAllowed.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/modelId.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/pluralApiName.ts create mode 100644 packages/api-headless-cms/src/domain/contentModel/validation/singularApiName.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index d8ff40fc645..de3d8a5b11b 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -14,7 +14,6 @@ import { createCmsModelFieldConvertersAttachFactory } from "~/utils/converters/v import { createExportCrud } from "~/export/index.js"; import { createImportCrud } from "~/export/crud/importing.js"; import { getSchema } from "~/graphql/getSchema.js"; -import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; import { CmsInstallerFeature } from "~/features/installer/feature.js"; import { ContentEntriesFeature } from "~/features/contentEntry/ContentEntriesFeature.js"; import { @@ -135,7 +134,6 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { }), ...createModelsCrud({ context, - getLocale, getTenant, getIdentity, storageOperations, diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 5a40af0cca3..b7d46f309f3 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -38,7 +38,6 @@ import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; import { createCacheKey, createMemoryCache } from "~/utils/index.js"; import { ensureTypeTag } from "./contentModel/ensureTypeTag.js"; -import { listModelsFromDatabase } from "~/crud/contentModel/listModelsFromDatabase.js"; import { filterAsync } from "~/utils/filterAsync.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import { @@ -46,12 +45,10 @@ import { CmsModelToAstConverter } from "~/utils/contentModelAst/index.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; export interface CreateModelsCrudParams { getTenant: () => Tenant; - getLocale: () => I18NLocale; storageOperations: HeadlessCmsStorageOperations; accessControl: AccessControl; context: CmsContext; @@ -59,7 +56,7 @@ export interface CreateModelsCrudParams { } export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContext => { - const { getTenant, getIdentity, getLocale, storageOperations, accessControl, context } = params; + const { getTenant, getIdentity, storageOperations, accessControl, context } = params; const listPluginModelsCache = createMemoryCache>(); const listFilteredModelsCache = createMemoryCache>(); diff --git a/packages/api-headless-cms/src/crud/contentModel/beforeCreate.ts b/packages/api-headless-cms/src/crud/contentModel/beforeCreate.ts deleted file mode 100644 index dc4db70adfa..00000000000 --- a/packages/api-headless-cms/src/crud/contentModel/beforeCreate.ts +++ /dev/null @@ -1,151 +0,0 @@ -import WebinyError from "@webiny/error"; -import camelCase from "lodash/camelCase.js"; -import type { - CmsContext, - CmsModel, - HeadlessCmsStorageOperations, - OnModelBeforeCreateFromTopicParams, - OnModelBeforeCreateTopicParams -} from "~/types/index.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import type { PluginsContainer } from "@webiny/plugins"; -import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; -import { validateModel } from "./validateModel.js"; -import { validateExistingModelId, validateModelIdAllowed } from "./validate/modelId.js"; -import { validateSingularApiName } from "./validate/singularApiName.js"; -import { validatePluralApiName } from "./validate/pluralApiName.js"; -import { validateEndingAllowed } from "~/crud/contentModel/validate/endingAllowed.js"; - -const getModelId = (model: CmsModel): string => { - const { modelId, name } = model; - const value = modelId ? modelId.trim() : null; - if (value) { - const isModelIdValid = camelCase(value).toLowerCase() === value.toLowerCase(); - if (isModelIdValid) { - return value; - } - return camelCase(value); - } else if (name) { - return camelCase(name.trim()); - } - throw new WebinyError( - `There is no "modelId" or "name" passed into the create model method.`, - "MISSING_MODEL_DATA", - { - model - } - ); -}; - -interface CreateOnModelBeforeCreateCbParams { - plugins: PluginsContainer; - storageOperations: HeadlessCmsStorageOperations; -} - -const createOnModelBeforeCb = ({ - plugins, - storageOperations -}: CreateOnModelBeforeCreateCbParams) => { - return async (params: OnModelBeforeCreateTopicParams | OnModelBeforeCreateFromTopicParams) => { - const { model: newModel } = params; - - const modelId = getModelId(newModel); - - newModel.modelId = modelId; - - const modelPlugin = plugins - .byType(CmsModelPlugin.type) - .find((item: CmsModelPlugin) => item.contentModel.modelId === modelId); - - if (modelPlugin) { - throw new WebinyError( - `Cannot create "${newModel.modelId}" content model because one is already registered via a plugin.`, - "CONTENT_MODEL_CREATE_ERROR", - { - modelId: newModel.modelId - } - ); - } - - const models = await storageOperations.models.list({ - where: { - tenant: newModel.tenant, - locale: newModel.locale - } - }); - - validateModelIdAllowed({ - model: newModel - }); - validateEndingAllowed({ - model: newModel - }); - /** - * We need to check for the existence of: - * - modelId - * - singularApiName - * - pluralApiName - */ - for (const model of models) { - validateExistingModelId({ - existingModel: model, - model: newModel - }); - validateSingularApiName({ - existingModel: model, - model: newModel - }); - validatePluralApiName({ - existingModel: model, - model: newModel - }); - } - }; -}; - -interface AssignBeforeModelCreateParams { - onModelBeforeCreate: Topic; - onModelBeforeCreateFrom: Topic; - storageOperations: HeadlessCmsStorageOperations; - context: CmsContext; -} - -/** - * We attach both on before create and createFrom events here. - * Callables are identical. - */ -export const assignModelBeforeCreate = (params: AssignBeforeModelCreateParams) => { - const { onModelBeforeCreate, onModelBeforeCreateFrom, storageOperations, context } = params; - - onModelBeforeCreate.subscribe(async ({ model, input }) => { - /** - * Run the shared create/createFrom methods. - */ - const cb = createOnModelBeforeCb({ - storageOperations, - plugins: context.plugins - }); - await cb({ - model, - input - }); - const models = await context.security.withoutAuthorization(async () => { - return context.cms.listModels(); - }); - /** - * and then we move onto model and fields... - */ - await validateModel({ - models, - model, - context - }); - }); - - onModelBeforeCreateFrom.subscribe( - createOnModelBeforeCb({ - storageOperations, - plugins: context.plugins - }) - ); -}; diff --git a/packages/api-headless-cms/src/domain/contentModel/createFieldStorageId.ts b/packages/api-headless-cms/src/domain/contentModel/createFieldStorageId.ts new file mode 100644 index 00000000000..79b88023208 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/createFieldStorageId.ts @@ -0,0 +1,7 @@ +import type { CmsModelField } from "~/types/index.js"; +import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; + +export const createFieldStorageId = (params: Pick): string => { + const { type, id } = params; + return `${getBaseFieldType({ type })}@${id}`; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation.ts b/packages/api-headless-cms/src/domain/contentModel/schemas.ts similarity index 100% rename from packages/api-headless-cms/src/domain/contentModel/validation.ts rename to packages/api-headless-cms/src/domain/contentModel/schemas.ts diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/endingAllowed.ts b/packages/api-headless-cms/src/domain/contentModel/validation/endingAllowed.ts new file mode 100644 index 00000000000..5cb628f1882 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/endingAllowed.ts @@ -0,0 +1,30 @@ +import WebinyError from "@webiny/error"; +import { disallowedEnding, isModelEndingAllowed } from "./isModelEndingAllowed.js"; +import type { CmsModel } from "~/types/index.js"; + +interface Params { + model: Pick; +} + +export const validateEndingAllowed = (params: Params): void => { + const { model } = params; + if (isModelEndingAllowed(model.singularApiName) === false) { + throw new WebinyError( + `Content model with singularApiName "${model.singularApiName}" is not allowed, as it ends in disallowed value.`, + "MODEL_SINGULAR_API_NAME_ENDING_NOT_ALLOWED", + { + input: model.singularApiName, + disallowedEnding + } + ); + } else if (isModelEndingAllowed(model.pluralApiName) === false) { + throw new WebinyError( + `Content model with pluralApiName "${model.pluralApiName}" is not allowed, as it ends in disallowed value.`, + "MODEL_PLURAL_API_NAME_NOT_ENDING_ALLOWED", + { + input: model.pluralApiName, + disallowedEnding: disallowedEnding + } + ); + } +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/fields/descriptionField.ts b/packages/api-headless-cms/src/domain/contentModel/validation/fields/descriptionField.ts new file mode 100644 index 00000000000..998f79f5753 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/fields/descriptionField.ts @@ -0,0 +1,29 @@ +import type { CmsModelField } from "~/types/index.js"; +import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; +import { getApplicableFieldById } from "./getApplicableFieldById.js"; + +const isFieldApplicable = (field: CmsModelField) => { + return getBaseFieldType(field) === "long-text" && !field.multipleValues; +}; + +/** + * Try finding the requested field, and return its `fieldId`. + * If not defined, or not applicable, fall back to the first applicable field. + */ +export const getContentModelDescriptionFieldId = ( + fields: CmsModelField[], + descriptionFieldId?: string | null +) => { + if (fields.length === 0) { + return null; + } + + const target = getApplicableFieldById(fields, descriptionFieldId, isFieldApplicable); + + if (target) { + return target.fieldId; + } + + const descriptionField = fields.find(isFieldApplicable); + return descriptionField ? descriptionField.fieldId : null; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/fields/getApplicableFieldById.ts b/packages/api-headless-cms/src/domain/contentModel/validation/fields/getApplicableFieldById.ts new file mode 100644 index 00000000000..4cfd43da328 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/fields/getApplicableFieldById.ts @@ -0,0 +1,13 @@ +import type { CmsModelField } from "~/types/index.js"; + +export const getApplicableFieldById = ( + fields: CmsModelField[], + id: string | null | undefined, + isApplicable: (field: CmsModelField) => boolean +) => { + if (!id) { + return undefined; + } + + return fields.find(field => field.fieldId === id && isApplicable(field)); +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/fields/imageField.ts b/packages/api-headless-cms/src/domain/contentModel/validation/fields/imageField.ts new file mode 100644 index 00000000000..a712165a459 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/fields/imageField.ts @@ -0,0 +1,27 @@ +import type { CmsModelField } from "~/types/index.js"; +import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; +import { getApplicableFieldById } from "./getApplicableFieldById.js"; + +const isFieldApplicable = (field: CmsModelField) => { + return Boolean( + getBaseFieldType(field) === "file" && !field.multipleValues && field.settings?.imagesOnly + ); +}; + +export const getContentModelImageFieldId = ( + fields: CmsModelField[], + imageFieldId?: string | null +) => { + if (fields.length === 0) { + return null; + } + + const target = getApplicableFieldById(fields, imageFieldId, isFieldApplicable); + + if (target) { + return target.fieldId; + } + + const imageField = fields.find(isFieldApplicable); + return imageField ? imageField.fieldId : null; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/fields/titleField.ts b/packages/api-headless-cms/src/domain/contentModel/validation/fields/titleField.ts new file mode 100644 index 00000000000..d0039ca3bba --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/fields/titleField.ts @@ -0,0 +1,31 @@ +import type { CmsModelField } from "~/types/index.js"; +import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; +import { getApplicableFieldById } from "~/crud/contentModel/fields/getApplicableFieldById.js"; + +const defaultTitleFieldId = "id"; + +const isFieldApplicable = (field: CmsModelField) => { + return getBaseFieldType(field) === "text" && !field.multipleValues; +}; + +/** + * Try finding the requested field, and return its `fieldId`. + * If not defined, or not applicable, fall back to the first applicable field. + */ +export const getContentModelTitleFieldId = ( + fields: CmsModelField[], + titleFieldId?: string | null +) => { + if (fields.length === 0) { + return defaultTitleFieldId; + } + + const target = getApplicableFieldById(fields, titleFieldId, isFieldApplicable); + + if (target) { + return target.fieldId; + } + + const textField = fields.find(isFieldApplicable); + return textField ? textField.fieldId : defaultTitleFieldId; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/isModelEndingAllowed.ts b/packages/api-headless-cms/src/domain/contentModel/validation/isModelEndingAllowed.ts new file mode 100644 index 00000000000..0593b9c1de4 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/isModelEndingAllowed.ts @@ -0,0 +1,24 @@ +/** + * This list is to disallow creating models that might interfere with GraphQL schema creation. + * Add more if required. + */ +export const disallowedEnding: string[] = [ + "Response", + "List", + "Meta", + "Input", + "Sorter", + "RefType" +]; + +export const isModelEndingAllowed = (apiName: string): boolean => { + for (const ending of disallowedEnding) { + const re = new RegExp(`${ending}$`); + const matched = apiName.match(re); + if (matched === null) { + continue; + } + return false; + } + return true; +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts b/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts new file mode 100644 index 00000000000..4174041721d --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts @@ -0,0 +1,396 @@ +import gql from "graphql-tag"; +import { generateAlphaNumericId } from "@webiny/utils"; +import WebinyError from "@webiny/error"; +import type { + CmsContext, + CmsModel, + CmsModelField, + CmsModelFieldToGraphQLPlugin, + CmsModelFieldToGraphQLPluginValidateChildFieldsValidate, + CmsModelLockedFieldPlugin, + LockedField +} from "~/types/index.js"; +import { createManageSDL } from "~/graphql/schema/createManageSDL.js"; +import { createFieldStorageId } from "../createFieldStorageId.js"; +import type { GraphQLError } from "graphql"; +import { getBaseFieldType } from "~/utils/getBaseFieldType.js"; +import { getContentModelTitleFieldId } from "./fields/titleField.js"; +import { getContentModelDescriptionFieldId } from "./fields/descriptionField.js"; +import { getContentModelImageFieldId } from "./fields/imageField.js"; +import type { ICmsGraphQLSchemaPlugin } from "~/plugins/index.js"; +import { CmsGraphQLSchemaPlugin, CmsGraphQLSchemaSorterPlugin } from "~/plugins/index.js"; +import { buildSchemaPlugins } from "~/graphql/buildSchemaPlugins.js"; +import { createExecutableSchema } from "~/graphql/createExecutableSchema.js"; + +const extractInvalidField = (model: CmsModel, err: GraphQLError) => { + const sdl = err.source?.body || ""; + + /** + * Find the invalid type + */ + const { line: lineNumber } = err.locations + ? err.locations[0] + : { + line: 0 + }; + const sdlLines = sdl.split("\n"); + let sdlLine; + let gqlType; + for (let i = lineNumber; i > 0; i--) { + if (sdlLine && sdlLine.includes("type ")) { + gqlType = sdlLine.match(/type\s+(.*?)\s+{/); + break; + } + + sdlLine = sdlLines[i]; + } + + let invalidField: string | undefined = undefined; + if (Array.isArray(gqlType)) { + const fieldRegex = new RegExp(`([^\\s+].*?):\\s+\\[?${gqlType[1]}!?\\]?`); + + const matched = sdl.match(fieldRegex); + if (matched) { + invalidField = matched[1]; + } + } + + let message = `See more details in the browser console.`; + if (invalidField) { + message = `Please review the definition of "${invalidField}" field.`; + } + + return { + data: { + modelId: model.modelId, + sdl, + invalidField + }, + code: "INVALID_MODEL_DEFINITION", + message: [`Model "${model.modelId}" was not saved!`, message].join("\n") + }; +}; + +const createValidateChildFields = ( + plugins: CmsModelFieldToGraphQLPlugin[] +): CmsModelFieldToGraphQLPluginValidateChildFieldsValidate => { + return ({ fields, originalFields }) => { + if (fields.length === 0 && originalFields.length === 0) { + return; + } + validateFields({ + fields, + originalFields, + plugins, + lockedFields: [] + }); + }; +}; + +interface ValidateFieldsParams { + plugins: CmsModelFieldToGraphQLPlugin[]; + fields: CmsModelField[]; + originalFields: CmsModelField[]; + lockedFields: LockedField[]; +} + +const validateFields = (params: ValidateFieldsParams) => { + const { plugins, fields, originalFields, lockedFields } = params; + + const idList: string[] = []; + const fieldIdList: string[] = []; + const storageIdList: string[] = []; + + for (const field of fields) { + const baseType = getBaseFieldType(field); + const plugin = plugins.find(plugin => plugin.fieldType === baseType); + + if (!plugin) { + throw new Error( + `Cannot update content model because of the unknown "${baseType}" field.` + ); + } + /** + * Check the field's id against existing ones. + * There cannot be two fields with the same id. + */ + if (idList.includes(field.id)) { + throw new WebinyError( + `Cannot update content model because field "${ + field.storageId || field.fieldId + }" has id "${field.id}", which is already used.` + ); + } + idList.push(field.id); + + const originalField = originalFields.find(f => f.id === field.id); + /** + * Field MUST have an fieldId defined. + */ + if (!field.fieldId) { + throw new WebinyError(`Field does not have an "fieldId" defined.`, "MISSING_FIELD_ID", { + field + }); + } + /** + * If storageId does not match a certain pattern, add that pattern, but only if field is not locked (used) already. + * This is to avoid errors in the already installed systems. + * + * Why are we using the @? + * + * It is not part of special characters for the query syntax in the Lucene. + * + * Relevant links: + * https://lucene.apache.org/core/3_4_0/queryparsersyntax.html + * https://discuss.elastic.co/t/special-characters-in-field-names/10658/3 + * https://discuss.elastic.co/t/illegal-characters-in-elasticsearch-field-names/17196/2 + */ + const isLocked = lockedFields.some(lockedField => { + return lockedField.fieldId === field.storageId || lockedField.fieldId === field.fieldId; + }); + if (!field.storageId) { + /** + * In case field is locked, we must set the storageId to the fieldId value. + * This should not happen, because we upgrade all the fields in 5.33.0, but let's have a check just in case of some upgrade miss. + */ + // + if (isLocked) { + field.storageId = field.fieldId; + } + /** + * When having original field, just set the storageId to value from the originalField + */ + // + else if (originalField) { + field.storageId = originalField.storageId; + } + /** + * The last case is when no original field and not locked - so this is a completely new field. + */ + // + else { + field.storageId = createFieldStorageId(field); + } + } + /** + * Check the field's fieldId against existing ones. + * There cannot be two fields with the same fieldId - outside world identifier. + */ + if (fieldIdList.includes(field.fieldId)) { + throw new WebinyError( + `Cannot update content model because field "${field.storageId}" has fieldId "${field.fieldId}", which is already used.` + ); + } + fieldIdList.push(field.fieldId); + /** + * Check the field's storageId against the existing ones. + * There cannot be two fields with the same storageId. + */ + if (storageIdList.includes(field.storageId)) { + throw new WebinyError( + `Cannot update content model because field "${field.label}" has storageId "${field.storageId}", which is already used.` + ); + } + storageIdList.push(field.storageId); + /** + * There might be some plugins which allow child fields. + * We use this method to validate them as well. + */ + if (!plugin.validateChildFields) { + continue; + } + const validateChildFields = createValidateChildFields(plugins); + plugin.validateChildFields({ + field, + originalField, + validate: validateChildFields + }); + } +}; + +interface CreateGraphQLSchemaParams { + context: CmsContext; + model: CmsModel; +} + +const createGraphQLSchema = async (params: CreateGraphQLSchemaParams): Promise => { + const { context, model } = params; + + const models = await context.security.withoutAuthorization(async () => { + return (await context.cms.listModels()).filter((model): model is CmsModel => { + return model.isPrivate !== true; + }); + }); + + const modelPlugins = await buildSchemaPlugins({ + context, + models: models.concat([model]) + }); + + const plugins = context.plugins + .byType(CmsGraphQLSchemaPlugin.type) + .filter(plugin => plugin.isApplicable(context)) + .reduce>((collection, plugin) => { + const name = + plugin.name || `${CmsGraphQLSchemaPlugin.type}-${generateAlphaNumericId(16)}`; + collection[name] = plugin; + return collection; + }, {}); + for (const plugin of modelPlugins) { + const name = plugin.name || `${plugin.type}-${generateAlphaNumericId(16)}`; + plugins[name] = plugin; + } + + return createExecutableSchema({ + plugins: Object.values(plugins) + }); +}; + +const extractErrorObject = (error: any) => { + return ["message", "code", "data", "stack"].reduce>((output, key) => { + if (!error[key]) { + return output; + } + output[key] = error[key]; + return output; + }, {}); +}; + +interface ValidateModelFieldsParams { + models: CmsModel[]; + model: CmsModel; + original?: CmsModel; + context: CmsContext; +} + +export const validateModelFields = async (params: ValidateModelFieldsParams): Promise => { + const { models, model, original, context } = params; + const { titleFieldId, descriptionFieldId, imageFieldId } = model; + const { plugins } = context; + + /** + * There should be fields/locked fields in either model or data to be updated. + */ + const { fields = [], lockedFields = [] } = model; + + /** + * Let's inspect the fields of the received content model. We prevent saving of a content model if it + * contains a field for which a "cms-model-field-to-graphql" plugin does not exist on the backend. + */ + const fieldTypePlugins = plugins.byType( + "cms-model-field-to-graphql" + ); + + validateFields({ + fields, + originalFields: original?.fields || [], + lockedFields, + plugins: fieldTypePlugins + }); + + if (fields.length) { + const sorterPlugins = plugins.byType( + CmsGraphQLSchemaSorterPlugin.type + ); + /** + * Make sure that this model can be safely converted to a GraphQL SDL + */ + const schema = createManageSDL({ + models, + model, + fieldTypePlugins: fieldTypePlugins.reduce( + (acc, pl) => ({ ...acc, [pl.fieldType]: pl }), + {} + ), + sorterPlugins + }); + + try { + gql(schema); + } catch (err) { + throw new WebinyError(extractInvalidField(model, err)); + } + /** + * + */ + try { + await createGraphQLSchema({ + context, + model + }); + } catch (err) { + throw new WebinyError({ + message: + "Cannot generate GraphQL schema when testing with the given model. Please check the response for more details.", + code: "GRAPHQL_SCHEMA_ERROR", + data: { + modelId: model.modelId, + error: extractErrorObject(err) + } + }); + } + } + + model.titleFieldId = getContentModelTitleFieldId(fields, titleFieldId); + model.descriptionFieldId = getContentModelDescriptionFieldId(fields, descriptionFieldId); + model.imageFieldId = getContentModelImageFieldId(fields, imageFieldId); + + const cmsLockedFieldPlugins = + plugins.byType("cms-model-locked-field"); + + /** + * We must not allow removal or changes in fields that are already in use in content entries. + * Locked fields still have fieldId (should be storageId) because of the old existing locked fields in the models. + */ + for (const lockedField of lockedFields) { + const existingField = fields.find(item => item.storageId === lockedField.fieldId); + + /** + * Starting with 5.33.0 fields can be deleted. + * Our UI gives a warning upon locked field deletion, but if user is managing fields through API directly - we cannot do anything. + */ + if (!existingField) { + continue; + } + + if (Boolean(lockedField.multipleValues) !== Boolean(existingField.multipleValues)) { + throw new WebinyError( + `Cannot change "multipleValues" for the "${lockedField.fieldId}" field because it's already in use in created content.`, + "ENTRY_FIELD_USED", + { + reason: `"multipleValues" changed`, + field: existingField + } + ); + } + + const fieldType = getBaseFieldType(existingField); + if (lockedField.type !== fieldType) { + throw new WebinyError( + `Cannot change field type for the "${lockedField.fieldId}" field because it's already in use in created content.`, + "ENTRY_FIELD_USED", + { + reason: `"type" changed`, + lockedFieldType: lockedField.type, + existingFieldType: fieldType + } + ); + } + + /** + * Check `lockedField` invariant for specific field + */ + const lockedFieldsByType = cmsLockedFieldPlugins.filter( + pl => pl.fieldType === getBaseFieldType(lockedField) + ); + for (const plugin of lockedFieldsByType) { + if (typeof plugin.checkLockedField !== "function") { + continue; + } + plugin.checkLockedField({ + lockedField, + field: existingField + }); + } + } +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/modelId.ts b/packages/api-headless-cms/src/domain/contentModel/validation/modelId.ts new file mode 100644 index 00000000000..e26d3acab23 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/modelId.ts @@ -0,0 +1,49 @@ +import WebinyError from "@webiny/error"; +import type { CmsModel } from "~/types/index.js"; + +const disallowedModelIdList: string[] = [ + "contentModel", + "contentModels", + "contentModelGroup", + "contentModelGroups" +]; +const isModelIdAllowed = (modelId: string): boolean => { + return disallowedModelIdList.includes(modelId) === false; +}; + +interface ModelIdAllowedParams { + model: Pick; +} + +export const validateModelIdAllowed = (params: ModelIdAllowedParams) => { + const { model } = params; + if (isModelIdAllowed(model.modelId)) { + return; + } + throw new WebinyError( + `Provided model ID "${model.modelId}" is not allowed.`, + "MODEL_ID_NOT_ALLOWED", + { + input: model.modelId + } + ); +}; + +interface Params { + existingModel: Pick; + model: Pick; +} + +export const validateExistingModelId = (params: Params): void => { + const { existingModel, model } = params; + + if (existingModel.modelId === model.modelId) { + throw new WebinyError( + `Content model with modelId "${model.modelId}" already exists.`, + "MODEL_ID_EXISTS", + { + input: existingModel.modelId + } + ); + } +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/pluralApiName.ts b/packages/api-headless-cms/src/domain/contentModel/validation/pluralApiName.ts new file mode 100644 index 00000000000..46b52dca578 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/pluralApiName.ts @@ -0,0 +1,29 @@ +import WebinyError from "@webiny/error"; +import type { CmsModel } from "~/types/index.js"; + +interface Params { + existingModel: Pick; + model: Pick; +} + +export const validatePluralApiName = (params: Params): void => { + const { existingModel, model } = params; + + if (existingModel.singularApiName === model.pluralApiName) { + throw new WebinyError( + `Content model with singularApiName "${model.pluralApiName}" already exists.`, + "MODEL_SINGULAR_API_NAME_EXISTS", + { + input: model.pluralApiName + } + ); + } else if (existingModel.pluralApiName === model.pluralApiName) { + throw new WebinyError( + `Content model with pluralApiName "${model.pluralApiName}" already exists.`, + "MODEL_PLURAL_API_NAME_EXISTS", + { + input: model.pluralApiName + } + ); + } +}; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/singularApiName.ts b/packages/api-headless-cms/src/domain/contentModel/validation/singularApiName.ts new file mode 100644 index 00000000000..43cd13381e7 --- /dev/null +++ b/packages/api-headless-cms/src/domain/contentModel/validation/singularApiName.ts @@ -0,0 +1,29 @@ +import WebinyError from "@webiny/error"; +import type { CmsModel } from "~/types/index.js"; + +interface Params { + existingModel: Pick; + model: Pick; +} + +export const validateSingularApiName = (params: Params): void => { + const { existingModel, model } = params; + + if (existingModel.singularApiName === model.singularApiName) { + throw new WebinyError( + `Content model with singularApiName "${model.singularApiName}" already exists.`, + "MODEL_SINGULAR_API_NAME_EXISTS", + { + input: model.singularApiName + } + ); + } else if (existingModel.pluralApiName === model.singularApiName) { + throw new WebinyError( + `Content model with pluralApiName "${model.singularApiName}" already exists.`, + "MODEL_PLURAL_API_NAME_EXISTS", + { + input: model.singularApiName + } + ); + } +}; diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index e6bca573e76..0bcf238cbf2 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -3,6 +3,7 @@ import { createMemoryCache } from "~/utils/index.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/PluginModelsProvider.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; +import { ModelsFetcher } from "~/features/contentModel/shared/ModelsFetcher.js"; import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; import { ListModelsFeature } from "~/features/contentModel/ListModels/feature.js"; @@ -13,6 +14,7 @@ export const ContentModelFeature = createFeature({ container.registerInstance(ModelCache, createMemoryCache()); container.register(PluginModelsProvider).inSingletonScope(); container.register(ModelToAstConverter); + container.register(ModelsFetcher).inSingletonScope(); // Query features GetModelFeature.register(container); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts index cd98a756cc0..c9ed2458c8d 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts @@ -1,21 +1,57 @@ +import camelCase from "lodash/camelCase.js"; import { Result } from "@webiny/feature/api"; import { CreateModelRepository as RepositoryAbstraction } from "./abstractions.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { + validateExistingModelId, + validateModelIdAllowed +} from "~/crud/contentModel/validate/modelId.js"; +import { validateEndingAllowed } from "~/crud/contentModel/validate/endingAllowed.js"; import type { CmsModel } from "~/types/index.js"; import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; +import { validateSingularApiName } from "~/domain/contentModel/validation/singularApiName.js"; +import { validatePluralApiName } from "~/domain/contentModel/validation/pluralApiName.js"; +import { validateModelFields } from "~/domain/contentModel/validation/modelFields.js"; /** - * CreateModelRepository - Validates and persists a new model. + * Generate modelId from model following the exact logic from beforeCreate.ts + */ +const getModelId = (model: { modelId?: string; name?: string }): string => { + const { modelId, name } = model; + const value = modelId ? modelId.trim() : null; + if (value) { + const isModelIdValid = camelCase(value).toLowerCase() === value.toLowerCase(); + if (isModelIdValid) { + return value; + } + return camelCase(value); + } else if (name) { + return camelCase(name.trim()); + } + throw new ModelValidationError( + `There is no "modelId" or "name" passed into the create model method.` + ); +}; + +/** + * CreateModelRepository - Validates domain rules and persists a new model. * * Responsibilities: - * - Validate modelId uniqueness - * - Validate API name uniqueness (singularApiName, pluralApiName) - * - Check for plugin model conflicts + * - Generate modelId from input + * - Validate modelId is allowed (not in disallowed list) + * - Validate API name endings + * - Validate modelId uniqueness (database + plugins) + * - Validate API name uniqueness (database + plugins) + * - Validate plugin conflicts + * - Validate model fields * - Persist to storage * - Clear ModelCache after successful create */ @@ -23,34 +59,44 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private modelCache: ModelCache.Interface, private pluginModelsProvider: PluginModelsProvider.Interface, + private listModelsUseCase: ListModelsUseCase.Interface, private storageOperations: StorageOperations.Interface, - private tenantContext: TenantContext.Interface + private tenantContext: TenantContext.Interface, + private cmsContext: CmsContext.Interface ) {} async execute(model: CmsModel): Promise> { try { const tenant = this.tenantContext.getTenant(); - // Validate modelId uniqueness - if (model.modelId) { - const existingById = await this.storageOperations.models.list({ - where: { - tenant: tenant.id, - modelId: model.modelId - } - }); + // TODO: this will eventually become part of the Model domain object. + const modelId = getModelId(model); + model.modelId = modelId; - if (existingById.length > 0) { - return Result.fail(new ModelSlugTakenError(model.modelId)); - } + // Validate modelId is allowed (not in disallowed list) + try { + validateModelIdAllowed({ model }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); } - // Validate API name uniqueness - const apiNameConflict = await this.checkApiNameConflict(model, tenant.id); - if (apiNameConflict) { - return Result.fail( - new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) - ); + // Validate API name endings + try { + validateEndingAllowed({ model }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Validate modelId uniqueness (database) + const existingById = await this.storageOperations.models.list({ + where: { + tenant: tenant.id, + modelId: model.modelId + } + }); + + if (existingById.length > 0) { + return Result.fail(new ModelSlugTakenError(model.modelId)); } // Check for plugin model conflicts @@ -69,7 +115,47 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { ); } - // TODO: ideally, this will eventually be handled by the CmsModel domain object + // Get all models for further validation + const modelsResult = await this.listModelsUseCase.execute(); + + if (modelsResult.isFail()) { + return Result.fail(new ModelPersistenceError(modelsResult.error)); + } + + const models = modelsResult.value; + + try { + /** + * We need to check for the existence of: + * - modelId + * - singularApiName + * - pluralApiName + */ + for (const existingModel of models) { + validateExistingModelId({ + existingModel, + model + }); + validateSingularApiName({ + existingModel, + model + }); + validatePluralApiName({ + existingModel, + model + }); + } + + await validateModelFields({ + models, + model, + context: this.cmsContext + }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // TODO: ideally, this will eventually be handled by the Model domain object model.tags = ensureTypeTag(model); // Persist to storage @@ -83,33 +169,16 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { return Result.fail(new ModelPersistenceError(error as Error)); } } - - private async checkApiNameConflict(model: CmsModel, tenant: string): Promise { - // Check singular API name - const existingBySingular = await this.storageOperations.models.list({ - where: { - tenant, - singularApiName: model.singularApiName - } - }); - - if (existingBySingular.length > 0) { - return true; - } - - // Check plural API name - const existingByPlural = await this.storageOperations.models.list({ - where: { - tenant, - pluralApiName: model.pluralApiName - } - }); - - return existingByPlural.length > 0; - } } export const CreateModelRepository = RepositoryAbstraction.createImplementation({ implementation: CreateModelRepositoryImpl, - dependencies: [ModelCache, PluginModelsProvider, StorageOperations, TenantContext] + dependencies: [ + ModelCache, + PluginModelsProvider, + ListModelsUseCase, + StorageOperations, + TenantContext, + CmsContext + ] }); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts index 88ed016e35e..086d3ddfb1e 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts @@ -15,16 +15,15 @@ import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { createZodError } from "@webiny/utils"; -import { mdbid } from "@webiny/utils"; import { removeUndefinedValues } from "@webiny/utils"; -import { createModelCreateValidation } from "~/domain/contentModel/validation.js"; +import { createModelCreateValidation } from "~/domain/contentModel/schemas.js"; import { assignModelDefaultFields } from "~/crud/contentModel/defaultFields.js"; import type { CmsModel } from "~/types/index.js"; import type { CmsModelCreateInput } from "~/types/index.js"; import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; /** - * CreateModelUseCase - Orchestrates model creation. + * CreateModelUseCase - Core model creation orchestration. * * Responsibilities: * - Validate input (Zod) @@ -34,6 +33,12 @@ import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js" * - Publish before event * - Delegate to repository * - Publish after event or error event + * + * Note: This use case is decorated by CreateModelValidator which handles: + * - ModelId generation + * - ModelId allowed validation + * - API name ending validation + * - Field validation and plugin conflict checks */ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -72,7 +77,6 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { const groupResult = await this.getGroupUseCase.execute(input.group); if (groupResult.isFail()) { - const error = groupResult.error; if (error.code === "Cms/ModelGroup/PersistenceError") { return Result.fail(new ModelPersistenceError(error)); @@ -87,10 +91,9 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { const identity = this.identityContext.getIdentity(); const tenant = this.tenantContext.getTenant(); - const modelId = data.modelId || mdbid(); const model: CmsModel = { ...data, - modelId, + modelId: "", // Will be set by repository tenant: tenant.id, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), @@ -121,9 +124,9 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { } // Publish before event - await this.eventPublisher.publish(new ModelBeforeCreateEvent({ model })); + await this.eventPublisher.publish(new ModelBeforeCreateEvent({ model, input: data })); - // Persist via repository + // Persist via repository (repository will validate and set modelId) const result = await this.repository.execute(model); if (result.isFail()) { // Publish error event diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts index ba7ac4a9365..793077f6004 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts @@ -39,7 +39,7 @@ export namespace CreateModelUseCase { } /** - * CreateModelRepository - Persists a new model to storage. + * CreateModelRepository - Validates domain rules and persists a new model to storage. */ export interface ICreateModelRepository { execute(model: CmsModel): Promise>; @@ -47,6 +47,7 @@ export interface ICreateModelRepository { export interface ICreateModelRepositoryErrors { alreadyExists: ModelSlugTakenError; + validation: ModelValidationError; persistence: ModelPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts index a788b9297f4..06d1587378b 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/events.ts @@ -9,6 +9,7 @@ import type { CmsModelCreateInput } from "~/types/index.js"; */ export interface ModelBeforeCreatePayload { model: CmsModel; + input: CmsModelCreateInput; } export interface ModelAfterCreatePayload { diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts index 931b96ea063..7bb08ec7125 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/feature.ts @@ -6,12 +6,12 @@ import { CreateModelRepository } from "./CreateModelRepository.js"; * CreateModel Feature * * Provides functionality for creating new content models. - * Includes validation, API name uniqueness checks, and plugin conflict checks. + * All validation (modelId generation, domain rules, uniqueness) is handled by the repository. */ export const CreateModelFeature = createFeature({ name: "CreateModel", register(container) { - // Register use case in transient scope (new instance per request) + // Register core use case container.register(CreateModelUseCase); // Register repository in singleton scope (shared instance) diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts index 3446879dde9..5dc224a244c 100644 --- a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts @@ -1,113 +1,38 @@ -import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { Result } from "@webiny/feature/api"; import { GetModelRepository as RepositoryAbstraction } from "./abstractions.js"; -import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; -import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; import { ModelNotFoundError } from "~/domain/contentModel/errors.js"; -import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; -import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; -import { CmsContext } from "~/features/shared/abstractions.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import { createCacheKey } from "~/utils/index.js"; import type { CmsModel } from "~/types/index.js"; -import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; /** * GetModelRepository - Fetches a single model by ID. * * Responsibilities: - * - Create cache keys based on tenant + locale + identity - * - Provide data loader functions to ModelCache - * - Fetch from plugin models + database models - * - Apply access control filtering - * - Ensure type tags + * - Use ModelsFetcher to get cached models * - Return the model or NotFoundError */ class GetModelRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private modelCache: ModelCache.Interface, - private pluginModelsProvider: PluginModelsProvider.Interface, - private storageOperations: StorageOperations.Interface, - private accessControl: AccessControl.Interface, - private tenantContext: TenantContext.Interface, - private identityContext: IdentityContext.Interface, - private cmsContext: CmsContext.Interface - ) {} + constructor(private modelsFetcher: ModelsFetcher.Interface) {} async execute(modelId: string): Promise> { - try { - const tenant = this.tenantContext.getTenant(); - const locale = this.cmsContext.getLocale(); + // Use ModelsFetcher which handles all caching, access control, and merging + const result = await this.modelsFetcher.fetchById(modelId); - // Fetch all models (plugin + database) with access control filtering - const models = await this.fetchAllModels(tenant.id, locale.code); - - const model = models.find(m => m.modelId === modelId); - - if (!model) { - return Result.fail(new ModelNotFoundError(modelId)); - } - - return Result.ok(model); - } catch (error) { - return Result.fail(new ModelPersistenceError(error as Error)); + if (result.isFail()) { + return result; } - } - private async fetchAllModels(tenant: string, locale: string): Promise { - // 1. Fetch plugin models (with caching and access control) - const pluginModels = await this.pluginModelsProvider.list(tenant); + const model = result.value; - // 2. Fetch database models (with caching) - const dbCacheKey = createCacheKey({ tenant, locale }); - const databaseModels = await this.modelCache.getOrSet(dbCacheKey, async () => { - return await this.storageOperations.models.list({ - where: { tenant, locale } - }); - }); - - // 3. Apply access control to database models (with caching) - const filteredCacheKey = createCacheKey({ - dbCacheKey: dbCacheKey.get(), - identity: this.cmsContext.security.isAuthorizationEnabled() - ? this.identityContext.getIdentity()?.id - : undefined - }); - - const filteredDatabaseModels = await this.modelCache.getOrSet( - filteredCacheKey, - async () => { - return filterAsync(databaseModels, async (model?: CmsModel) => { - if (!model) { - return false; - } - return this.accessControl.canAccessModel({ model }); - }); - } - ); - - // 4. Ensure type tags on database models - const taggedDatabaseModels = filteredDatabaseModels.map(model => { - model.tags = ensureTypeTag(model); - return model; - }); + if (!model) { + return Result.fail(new ModelNotFoundError(modelId)); + } - // 5. Merge plugin + database models - return [...pluginModels, ...taggedDatabaseModels]; + return Result.ok(model); } } export const GetModelRepository = RepositoryAbstraction.createImplementation({ implementation: GetModelRepositoryImpl, - dependencies: [ - ModelCache, - PluginModelsProvider, - StorageOperations, - AccessControl, - TenantContext, - IdentityContext, - CmsContext - ] + dependencies: [ModelsFetcher] }); diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts index 47ad18031b5..f0465579d93 100644 --- a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts @@ -1,110 +1,52 @@ -import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { Result } from "@webiny/feature/api"; import { ListModelsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; -import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; -import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; -import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; -import { filterAsync } from "~/utils/filterAsync.js"; -import { createCacheKey } from "~/utils/index.js"; +import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; import type { ICmsModelListParams } from "~/types/index.js"; -import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; /** - * ListModelsRepository - Fetches all models. + * ListModelsRepository - Fetches all models with optional filters. * * Responsibilities: - * - Create cache keys based on tenant + identity - * - Provide data loader functions to ModelCache - * - Fetch from plugin models + database models - * - Apply access control filtering + * - Use ModelsFetcher to get cached models * - Apply includePrivate and includePlugins filters - * - Ensure type tags * - Return all accessible models */ class ListModelsRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private modelCache: ModelCache.Interface, - private pluginModelsProvider: PluginModelsProvider.Interface, - private storageOperations: StorageOperations.Interface, - private accessControl: AccessControl.Interface, - private tenantContext: TenantContext.Interface, - private identityContext: IdentityContext.Interface - ) {} + constructor(private modelsFetcher: ModelsFetcher.Interface) {} async execute( params?: ICmsModelListParams ): Promise> { - try { - const tenant = this.tenantContext.getTenant(); - - // Default params - const includePrivate = params?.includePrivate !== false; // defaults to true - const includePlugins = params?.includePlugins !== false; // defaults to true + // Default params + const includePrivate = params?.includePrivate !== false; // defaults to true + const includePlugins = params?.includePlugins !== false; // defaults to true - // Fetch all models (plugin + database) with access control filtering - let models = await this.fetchAllModels(tenant.id, includePlugins); + // Use ModelsFetcher which handles all caching, access control, and merging + const result = await this.modelsFetcher.fetchAll(); - // Filter out private models if requested - if (!includePrivate) { - models = models.filter(model => model.isPrivate !== true); - } - - return Result.ok(models); - } catch (error) { - return Result.fail(new ModelPersistenceError(error as Error)); + if (result.isFail()) { + return Result.fail(new ModelPersistenceError(result.error)); } - } - private async fetchAllModels(tenant: string, includePlugins: boolean): Promise { - // 1. Fetch plugin models (with caching and access control) if requested - const pluginModels = includePlugins ? await this.pluginModelsProvider.list(tenant) : []; + let models = result.value; - // 2. Fetch database models (with caching) - const dbCacheKey = createCacheKey({ tenant }); - const databaseModels = await this.modelCache.getOrSet(dbCacheKey, () => { - return this.storageOperations.models.list({ where: { tenant } }); - }); - - // 3. Apply access control to database models (with caching) - const filteredCacheKey = createCacheKey({ - dbCacheKey: dbCacheKey.get(), - identity: this.identityContext.isAuthorizationEnabled() - ? this.identityContext.getIdentity()?.id - : undefined - }); - - const filteredDatabaseModels = await this.modelCache.getOrSet(filteredCacheKey, () => { - return filterAsync(databaseModels, async (model: CmsModel) => { - if (!model) { - return false; - } - return this.accessControl.canAccessModel({ model }); - }); - }); + // Filter out plugin models if requested + if (!includePlugins) { + models = models.filter(model => !model.isPlugin); + } - // 4. Ensure type tags on database models - const taggedDatabaseModels = filteredDatabaseModels.map(model => { - model.tags = ensureTypeTag(model); - return model; - }); + // Filter out private models if requested + if (!includePrivate) { + models = models.filter(model => model.isPrivate !== true); + } - // 5. Merge plugin + database models - return [...pluginModels, ...taggedDatabaseModels]; + return Result.ok(models); } } export const ListModelsRepository = RepositoryAbstraction.createImplementation({ implementation: ListModelsRepositoryImpl, - dependencies: [ - ModelCache, - PluginModelsProvider, - StorageOperations, - AccessControl, - TenantContext, - IdentityContext - ] + dependencies: [ModelsFetcher] }); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts new file mode 100644 index 00000000000..c00ed2491d7 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts @@ -0,0 +1,118 @@ +import { Result } from "@webiny/feature/api"; +import { ModelCache, ModelsFetcher as FetcherAbstraction } from "~/features/contentModel/shared/abstractions.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { ModelNotFoundError, ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { filterAsync } from "~/utils/filterAsync.js"; +import { createCacheKey } from "~/utils/index.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * ModelsFetcherImpl - Implementation with multi-level caching. + * + * Caching strategy: + * 1. Plugin models are cached per tenant (with access control applied by PluginModelsProvider) + * 2. Database models are cached per tenant (raw from DB) + * 3. Filtered database models are cached per tenant + identity (with access control applied) + * 4. Final merged list is cached per tenant + identity + */ +class ModelsFetcherImpl implements FetcherAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private storageOperations: StorageOperations.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async fetchAll(): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // Create a cache key based on tenant + identity + const cacheKey = createCacheKey({ + tenant: tenant.id, + identity: this.identityContext.isAuthorizationEnabled() + ? this.identityContext.getIdentity().id + : undefined + }); + + // Try to get from cache first + const cached = await this.modelCache.getOrSet(cacheKey, async () => { + return this.fetchAndMergeModels(tenant.id); + }); + + return Result.ok(cached); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } + + async fetchById(modelId: string): Promise> { + const result = await this.fetchAll(); + if (result.isFail()) { + return Result.fail(new ModelPersistenceError(result.error)); + } + + const model = result.value.find(m => m.modelId === modelId); + if (!model) { + return Result.fail(new ModelNotFoundError(modelId)); + } + + return Result.ok(model); + } + + private async fetchAndMergeModels(tenant: string): Promise { + // 1. Fetch plugin models (with caching and access control) + const pluginModels = await this.pluginModelsProvider.list(tenant); + + // 2. Fetch database models (with caching) + const dbCacheKey = createCacheKey({ tenant }); + const databaseModels = await this.modelCache.getOrSet(dbCacheKey, () => { + return this.storageOperations.models.list({ where: { tenant } }); + }); + + // 3. Apply access control to database models (with caching) + const filteredCacheKey = createCacheKey({ + dbCacheKey: dbCacheKey.get(), + identity: this.identityContext.isAuthorizationEnabled() + ? this.identityContext.getIdentity()?.id + : undefined + }); + + const filteredDatabaseModels = await this.modelCache.getOrSet(filteredCacheKey, () => { + return filterAsync(databaseModels, async (model: CmsModel) => { + if (!model) { + return false; + } + return this.accessControl.canAccessModel({ model }); + }); + }); + + // 4. Ensure type tags on database models + const taggedDatabaseModels = filteredDatabaseModels.map(model => { + model.tags = ensureTypeTag(model); + return model; + }); + + // 5. Merge plugin + database models + return [...pluginModels, ...taggedDatabaseModels]; + } +} + +export const ModelsFetcher = FetcherAbstraction.createImplementation({ + implementation: ModelsFetcherImpl, + dependencies: [ + ModelCache, + PluginModelsProvider, + StorageOperations, + AccessControl, + TenantContext, + IdentityContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index 4fd4c818469..1c92a247db2 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -1,6 +1,7 @@ -import { createAbstraction } from "@webiny/feature/api"; +import { createAbstraction, Result } from "@webiny/feature/api"; import type { CmsModel, CmsModelAst } from "~/types/index.js"; import type { ICache } from "~/utils/caching/types.js"; +import { ModelNotFoundError, ModelPersistenceError } from "~/domain/contentModel/errors.js"; /** * PluginModelsProvider provides access to plugin-defined (code) models. @@ -34,3 +35,37 @@ export const ModelToAstConverter = createAbstraction("Mode export namespace ModelToAstConverter { export type Interface = IModelToAstConverter; } + +/** + * ModelsFetcher - Centralized model fetching with caching. + * + * This abstraction handles fetching models from both plugins and database, + * applies access control filtering, and caches the results for optimal performance. + */ +export interface IModelsFetcher { + /** + * Fetch all accessible models for the current tenant and identity. + * Results are cached based on tenant + identity. + */ + fetchAll(): Promise>; + + /** + * Fetch a single model by modelId. + * Uses the cached fetchAll result. + */ + fetchById(modelId: string): Promise>; +} + +export interface IModelsFetcherErrors { + notFound: ModelNotFoundError; + persistence: ModelPersistenceError; +} + +type ModelsFetcherError = IModelsFetcherErrors[keyof IModelsFetcherErrors]; + +export const ModelsFetcher = createAbstraction("ModelsFetcher"); + +export namespace ModelsFetcher { + export type Interface = IModelsFetcher; + export type Error = ModelsFetcherError; +} From 772e0d7a3dd2ca844eb41d423a8f448f2075d634 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 11:58:57 +0100 Subject: [PATCH 25/71] wip: migrate content model crud --- .../src/crud/contentModel.crud.ts | 209 ++---------------- .../crud/contentModel/validateModelFields.ts | 96 +------- .../contentModel/validation/modelFields.ts | 95 +------- .../contentModel/ContentModelFeature.ts | 4 + .../DeleteModel/DeleteModelRepository.ts | 41 ++++ .../DeleteModel/DeleteModelUseCase.ts | 80 +++++++ .../contentModel/DeleteModel/abstractions.ts | 51 +++++ .../contentModel/DeleteModel/events.ts | 78 +++++++ .../contentModel/DeleteModel/feature.ts | 19 ++ .../contentModel/DeleteModel/index.ts | 1 + .../UpdateModel/UpdateModelRepository.ts | 99 +++++++++ .../UpdateModel/UpdateModelUseCase.ts | 180 +++++++++++++++ .../contentModel/UpdateModel/abstractions.ts | 64 ++++++ .../contentModel/UpdateModel/events.ts | 84 +++++++ .../contentModel/UpdateModel/feature.ts | 20 ++ .../contentModel/UpdateModel/index.ts | 1 + packages/api-headless-cms/src/types/model.ts | 6 +- .../api-headless-cms/src/types/modelField.ts | 25 --- .../api-headless-cms/src/types/plugins.ts | 28 +-- 19 files changed, 758 insertions(+), 423 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/index.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/UpdateModel/index.ts diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index b7d46f309f3..419d8ee6349 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -5,7 +5,6 @@ import type { CmsModel, CmsModelContext, CmsModelFieldToGraphQLPlugin, - CmsModelGroup, CmsModelManager, CmsModelUpdateInput, HeadlessCmsStorageOperations, @@ -27,18 +26,14 @@ import type { import { NotFoundError } from "@webiny/handler-graphql"; import { contentModelManagerFactory } from "./contentModel/contentModelManagerFactory.js"; import { createTopic } from "@webiny/pubsub"; -import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; -import { - createModelCreateFromValidation, - createModelUpdateValidation -} from "~/crud/contentModel/validation.js"; +import { createModelCreateFromValidation } from "~/crud/contentModel/validation.js"; import { createZodError, removeUndefinedValues } from "@webiny/utils"; import { CreateModelUseCase } from "~/features/contentModel/CreateModel/index.js"; +import { UpdateModelUseCase } from "~/features/contentModel/UpdateModel/index.js"; +import { DeleteModelUseCase } from "~/features/contentModel/DeleteModel/index.js"; import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; -import { createCacheKey, createMemoryCache } from "~/utils/index.js"; -import { ensureTypeTag } from "./contentModel/ensureTypeTag.js"; -import { filterAsync } from "~/utils/filterAsync.js"; +import { createMemoryCache } from "~/utils/index.js"; import type { AccessControl } from "./AccessControl/AccessControl.js"; import { CmsModelFieldToAstConverterFromPlugins, @@ -58,7 +53,6 @@ export interface CreateModelsCrudParams { export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContext => { const { getTenant, getIdentity, storageOperations, accessControl, context } = params; - const listPluginModelsCache = createMemoryCache>(); const listFilteredModelsCache = createMemoryCache>(); const listDatabaseModelsCache = createMemoryCache>(); const clearModelsCache = (): void => { @@ -86,65 +80,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex ); }; - const listPluginModels = async (tenant: string, locale: string): Promise => { - const modelPlugins = context.plugins.byType(CmsModelPlugin.type); - const cacheKey = createCacheKey({ - tenant, - locale, - models: modelPlugins - .map(({ contentModel: model }) => { - return `${model.modelId}#${model.pluralApiName}#${model.singularApiName}#${ - model.savedOn || "savedOn:plugin" - }`; - }) - .join("/"), - identity: context.security.isAuthorizationEnabled() ? getIdentity()?.id : undefined - }); - return listPluginModelsCache.getOrSet(cacheKey, async () => { - const models = modelPlugins - /** - * We need to filter out models that are not for this tenant or locale. - * If it does not have tenant or locale define, it is for every locale and tenant - */ - .filter(plugin => { - const { tenant: modelTenant, locale: modelLocale } = plugin.contentModel; - if (modelTenant && modelTenant !== tenant) { - return false; - } else if (modelLocale && modelLocale !== locale) { - return false; - } - return true; - }) - .map(plugin => { - return { - ...plugin.contentModel, - tags: ensureTypeTag(plugin.contentModel), - tenant, - locale, - webinyVersion: context.WEBINY_VERSION - }; - }) as unknown as CmsModel[]; - - return filterAsync(models, async model => { - return accessControl.canAccessModel({ model }); - }); - }); - }; - - const getModelFromCache = async (modelId: string) => { - const models = await listModels(); - const model = models.find(m => m.modelId === modelId); - if (!model) { - throw new NotFoundError(`Content model "${modelId}" was not found!`); - } - - return { - ...model, - tags: ensureTypeTag(model), - tenant: model.tenant || getTenant().id - }; - }; - /** * The list models cache is a key -> Promise pair so it the listModels() can be called multiple times but executed only once. * @@ -226,97 +161,24 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex const result = await useCase.execute(input); if (result.isFail()) { - const code = result.error.code; throw new WebinyError(result.error.message, result.error.code, result.error.data); } return result.value; }; const updateModel: CmsModelContext["updateModel"] = async (modelId, input) => { - await accessControl.ensureCanAccessModel({ rwd: "w" }); - - // Get a model record; this will also perform ownership validation. - const original = await getModel(modelId); - - const result = await createModelUpdateValidation().safeParseAsync(input); - if (!result.success) { - throw createZodError(result.error); - } + // Delegate to new UpdateModel use case + const useCase = context.container.resolve(UpdateModelUseCase); + const result = await useCase.execute(modelId, input); - const data = removeUndefinedValues(result.data); - - if (Object.keys(data).length === 0) { - /** - * We need to return the original if nothing is to be updated. - */ - return original; - } - let group: CmsModelGroup = { - id: original.group.id, - name: original.group.name - }; - const groupId = data.group; - if (groupId) { - const groupData = await context.cms.getGroup(groupId); - group = { - id: groupData.id, - name: groupData.name - }; + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } - const model: CmsModel = { - ...original, - ...data, - titleFieldId: - data.titleFieldId === undefined - ? original.titleFieldId - : (data.titleFieldId as string), - descriptionFieldId: - data.descriptionFieldId === undefined - ? original.descriptionFieldId - : data.descriptionFieldId, - imageFieldId: - data.imageFieldId === undefined ? original.imageFieldId : data.imageFieldId, - group, - description: data.description || original.description, - tenant: original.tenant || getTenant().id, - webinyVersion: context.WEBINY_VERSION, - savedOn: new Date().toISOString() - }; - - await accessControl.ensureCanAccessModel({ model, rwd: "w" }); - - model.tags = ensureTypeTag(model); - - try { - await onModelBeforeUpdate.publish({ - input: data, - original, - model - }); - - const resultModel = await storageOperations.models.update({ - model - }); - await updateManager(context, resultModel); + // Update manager after successful update + await updateManager(context, result.value); - await onModelAfterUpdate.publish({ - input: data, - original, - model: resultModel - }); - - return resultModel; - } catch (ex) { - await onModelUpdateError.publish({ - input: data, - model, - original, - error: ex - }); - - throw ex; - } + return result.value; }; const updateModelDirect: CmsModelContext["updateModelDirect"] = async params => { const { model: initialModel, original } = params; @@ -408,7 +270,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex }, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - lockedFields: [], webinyVersion: context.WEBINY_VERSION }; @@ -447,46 +308,16 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex } }; const deleteModel: CmsModelContext["deleteModel"] = async modelId => { - await accessControl.ensureCanAccessModel({ rwd: "d" }); - - const model = await getModel(modelId); + // Delegate to new DeleteModel use case + const useCase = context.container.resolve(DeleteModelUseCase); + const result = await useCase.execute(modelId); - await accessControl.ensureCanAccessModel({ model, rwd: "d" }); - - try { - await onModelBeforeDelete.publish({ - model - }); - - try { - await storageOperations.models.delete({ - model - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete the content model", - ex.code || "CONTENT_MODEL_DELETE_ERROR", - { - error: ex, - modelId: model.modelId - } - ); - } - - clearModelsCache(); - - await onModelAfterDelete.publish({ - model - }); - - managers.delete(model.modelId); - } catch (ex) { - await onModelDeleteError.publish({ - model, - error: ex - }); - throw ex; + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } + + // Clean up manager after successful deletion + managers.delete(modelId); }; const initializeModel: CmsModelContext["initializeModel"] = async (modelId, data) => { /** diff --git a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts index 8fb999e2179..06d11f8fd37 100644 --- a/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts +++ b/packages/api-headless-cms/src/crud/contentModel/validateModelFields.ts @@ -5,9 +5,7 @@ import type { CmsModel, CmsModelField, CmsModelFieldToGraphQLPlugin, - CmsModelFieldToGraphQLPluginValidateChildFieldsValidate, - CmsModelLockedFieldPlugin, - LockedField + CmsModelFieldToGraphQLPluginValidateChildFieldsValidate } from "~/types/index.js"; import { createManageSDL } from "~/graphql/schema/createManageSDL.js"; import { createFieldStorageId } from "./createFieldStorageId.js"; @@ -81,8 +79,7 @@ const createValidateChildFields = ( validateFields({ fields, originalFields, - plugins, - lockedFields: [] + plugins }); }; }; @@ -91,11 +88,10 @@ interface ValidateFieldsParams { plugins: CmsModelFieldToGraphQLPlugin[]; fields: CmsModelField[]; originalFields: CmsModelField[]; - lockedFields: LockedField[]; } const validateFields = (params: ValidateFieldsParams) => { - const { plugins, fields, originalFields, lockedFields } = params; + const { plugins, fields, originalFields } = params; const idList: string[] = []; const fieldIdList: string[] = []; @@ -145,30 +141,14 @@ const validateFields = (params: ValidateFieldsParams) => { * https://discuss.elastic.co/t/special-characters-in-field-names/10658/3 * https://discuss.elastic.co/t/illegal-characters-in-elasticsearch-field-names/17196/2 */ - const isLocked = lockedFields.some(lockedField => { - return lockedField.fieldId === field.storageId || lockedField.fieldId === field.fieldId; - }); if (!field.storageId) { - /** - * In case field is locked, we must set the storageId to the fieldId value. - * This should not happen, because we upgrade all the fields in 5.33.0, but let's have a check just in case of some upgrade miss. - */ - // - if (isLocked) { - field.storageId = field.fieldId; - } /** * When having original field, just set the storageId to value from the originalField */ // - else if (originalField) { + if (originalField) { field.storageId = originalField.storageId; - } - /** - * The last case is when no original field and not locked - so this is a completely new field. - */ - // - else { + } else { field.storageId = createFieldStorageId(field); } } @@ -271,7 +251,7 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr /** * There should be fields/locked fields in either model or data to be updated. */ - const { fields = [], lockedFields = [] } = model; + const { fields = [] } = model; /** * Let's inspect the fields of the received content model. We prevent saving of a content model if it @@ -284,7 +264,6 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr validateFields({ fields, originalFields: original?.fields || [], - lockedFields, plugins: fieldTypePlugins }); @@ -310,9 +289,7 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr } catch (err) { throw new WebinyError(extractInvalidField(model, err)); } - /** - * - */ + try { await createGraphQLSchema({ context, @@ -334,63 +311,4 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr model.titleFieldId = getContentModelTitleFieldId(fields, titleFieldId); model.descriptionFieldId = getContentModelDescriptionFieldId(fields, descriptionFieldId); model.imageFieldId = getContentModelImageFieldId(fields, imageFieldId); - - const cmsLockedFieldPlugins = - plugins.byType("cms-model-locked-field"); - - /** - * We must not allow removal or changes in fields that are already in use in content entries. - * Locked fields still have fieldId (should be storageId) because of the old existing locked fields in the models. - */ - for (const lockedField of lockedFields) { - const existingField = fields.find(item => item.storageId === lockedField.fieldId); - - /** - * Starting with 5.33.0 fields can be deleted. - * Our UI gives a warning upon locked field deletion, but if user is managing fields through API directly - we cannot do anything. - */ - if (!existingField) { - continue; - } - - if (Boolean(lockedField.multipleValues) !== Boolean(existingField.multipleValues)) { - throw new WebinyError( - `Cannot change "multipleValues" for the "${lockedField.fieldId}" field because it's already in use in created content.`, - "ENTRY_FIELD_USED", - { - reason: `"multipleValues" changed`, - field: existingField - } - ); - } - - const fieldType = getBaseFieldType(existingField); - if (lockedField.type !== fieldType) { - throw new WebinyError( - `Cannot change field type for the "${lockedField.fieldId}" field because it's already in use in created content.`, - "ENTRY_FIELD_USED", - { - reason: `"type" changed`, - lockedFieldType: lockedField.type, - existingFieldType: fieldType - } - ); - } - - /** - * Check `lockedField` invariant for specific field - */ - const lockedFieldsByType = cmsLockedFieldPlugins.filter( - pl => pl.fieldType === getBaseFieldType(lockedField) - ); - for (const plugin of lockedFieldsByType) { - if (typeof plugin.checkLockedField !== "function") { - continue; - } - plugin.checkLockedField({ - lockedField, - field: existingField - }); - } - } }; diff --git a/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts b/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts index 4174041721d..f2ca06f141a 100644 --- a/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts +++ b/packages/api-headless-cms/src/domain/contentModel/validation/modelFields.ts @@ -6,9 +6,7 @@ import type { CmsModel, CmsModelField, CmsModelFieldToGraphQLPlugin, - CmsModelFieldToGraphQLPluginValidateChildFieldsValidate, - CmsModelLockedFieldPlugin, - LockedField + CmsModelFieldToGraphQLPluginValidateChildFieldsValidate } from "~/types/index.js"; import { createManageSDL } from "~/graphql/schema/createManageSDL.js"; import { createFieldStorageId } from "../createFieldStorageId.js"; @@ -81,8 +79,7 @@ const createValidateChildFields = ( validateFields({ fields, originalFields, - plugins, - lockedFields: [] + plugins }); }; }; @@ -91,11 +88,10 @@ interface ValidateFieldsParams { plugins: CmsModelFieldToGraphQLPlugin[]; fields: CmsModelField[]; originalFields: CmsModelField[]; - lockedFields: LockedField[]; } const validateFields = (params: ValidateFieldsParams) => { - const { plugins, fields, originalFields, lockedFields } = params; + const { plugins, fields, originalFields } = params; const idList: string[] = []; const fieldIdList: string[] = []; @@ -145,30 +141,13 @@ const validateFields = (params: ValidateFieldsParams) => { * https://discuss.elastic.co/t/special-characters-in-field-names/10658/3 * https://discuss.elastic.co/t/illegal-characters-in-elasticsearch-field-names/17196/2 */ - const isLocked = lockedFields.some(lockedField => { - return lockedField.fieldId === field.storageId || lockedField.fieldId === field.fieldId; - }); if (!field.storageId) { /** - * In case field is locked, we must set the storageId to the fieldId value. - * This should not happen, because we upgrade all the fields in 5.33.0, but let's have a check just in case of some upgrade miss. + * When having an original field, set the storageId to value from the originalField */ - // - if (isLocked) { - field.storageId = field.fieldId; - } - /** - * When having original field, just set the storageId to value from the originalField - */ - // - else if (originalField) { + if (originalField) { field.storageId = originalField.storageId; - } - /** - * The last case is when no original field and not locked - so this is a completely new field. - */ - // - else { + } else { field.storageId = createFieldStorageId(field); } } @@ -271,7 +250,7 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr /** * There should be fields/locked fields in either model or data to be updated. */ - const { fields = [], lockedFields = [] } = model; + const { fields = [] } = model; /** * Let's inspect the fields of the received content model. We prevent saving of a content model if it @@ -284,7 +263,6 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr validateFields({ fields, originalFields: original?.fields || [], - lockedFields, plugins: fieldTypePlugins }); @@ -334,63 +312,4 @@ export const validateModelFields = async (params: ValidateModelFieldsParams): Pr model.titleFieldId = getContentModelTitleFieldId(fields, titleFieldId); model.descriptionFieldId = getContentModelDescriptionFieldId(fields, descriptionFieldId); model.imageFieldId = getContentModelImageFieldId(fields, imageFieldId); - - const cmsLockedFieldPlugins = - plugins.byType("cms-model-locked-field"); - - /** - * We must not allow removal or changes in fields that are already in use in content entries. - * Locked fields still have fieldId (should be storageId) because of the old existing locked fields in the models. - */ - for (const lockedField of lockedFields) { - const existingField = fields.find(item => item.storageId === lockedField.fieldId); - - /** - * Starting with 5.33.0 fields can be deleted. - * Our UI gives a warning upon locked field deletion, but if user is managing fields through API directly - we cannot do anything. - */ - if (!existingField) { - continue; - } - - if (Boolean(lockedField.multipleValues) !== Boolean(existingField.multipleValues)) { - throw new WebinyError( - `Cannot change "multipleValues" for the "${lockedField.fieldId}" field because it's already in use in created content.`, - "ENTRY_FIELD_USED", - { - reason: `"multipleValues" changed`, - field: existingField - } - ); - } - - const fieldType = getBaseFieldType(existingField); - if (lockedField.type !== fieldType) { - throw new WebinyError( - `Cannot change field type for the "${lockedField.fieldId}" field because it's already in use in created content.`, - "ENTRY_FIELD_USED", - { - reason: `"type" changed`, - lockedFieldType: lockedField.type, - existingFieldType: fieldType - } - ); - } - - /** - * Check `lockedField` invariant for specific field - */ - const lockedFieldsByType = cmsLockedFieldPlugins.filter( - pl => pl.fieldType === getBaseFieldType(lockedField) - ); - for (const plugin of lockedFieldsByType) { - if (typeof plugin.checkLockedField !== "function") { - continue; - } - plugin.checkLockedField({ - lockedField, - field: existingField - }); - } - } }; diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index 0bcf238cbf2..184c2d7cae5 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -5,6 +5,8 @@ import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; import { ModelsFetcher } from "~/features/contentModel/shared/ModelsFetcher.js"; import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; +import { UpdateModelFeature } from "~/features/contentModel/UpdateModel/feature.js"; +import { DeleteModelFeature } from "~/features/contentModel/DeleteModel/feature.js"; import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; import { ListModelsFeature } from "~/features/contentModel/ListModels/feature.js"; @@ -22,5 +24,7 @@ export const ContentModelFeature = createFeature({ // Command features CreateModelFeature.register(container); + UpdateModelFeature.register(container); + DeleteModelFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts new file mode 100644 index 00000000000..d093e98c3a1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteModelRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * DeleteModelRepository - Deletes a model from storage. + * + * Responsibilities: + * - Delete from storage + * - Clear ModelCache after successful deletion + * + * Note: Validation (checking for entries, plugin models) should be done in event handlers + */ +class DeleteModelRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private storageOperations: StorageOperations.Interface + ) {} + + async execute(model: CmsModel): Promise> { + try { + // Delete from storage + await this.storageOperations.models.delete({ model }); + + // Clear cache + this.modelCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } +} + +export const DeleteModelRepository = RepositoryAbstraction.createImplementation({ + implementation: DeleteModelRepositoryImpl, + dependencies: [ModelCache, StorageOperations] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelUseCase.ts new file mode 100644 index 00000000000..8530c1a41bf --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelUseCase.ts @@ -0,0 +1,80 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteModelUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { DeleteModelRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { ModelBeforeDeleteEvent } from "./events.js"; +import { ModelAfterDeleteEvent } from "./events.js"; +import { ModelDeleteErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { ModelNotAuthorizedError } from "~/domain/contentModel/errors.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; + +/** + * DeleteModelUseCase - Core model deletion orchestration. + * + * Responsibilities: + * - Fetch model + * - Access control checks + * - Publish before event + * - Delegate to repository for deletion + * - Publish after event or error event + * + * Note: Validation (e.g., checking for entries, plugin models) should be done in event handlers + */ +class DeleteModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private eventPublisher: EventPublisher.Interface, + private repository: DeleteModelRepository.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute(modelId: string): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessModel({ rwd: "d" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Get the model (with access control check) + const getResult = await this.getModelUseCase.execute(modelId); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + + const model = getResult.value; + + // Access control check on the specific model + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "d" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + // Publish before event + await this.eventPublisher.publish(new ModelBeforeDeleteEvent({ model })); + + // Delete via repository + const result = await this.repository.execute(model); + + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new ModelDeleteErrorEvent({ + model, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish(new ModelAfterDeleteEvent({ model })); + + return Result.ok(); + } +} + +export const DeleteModelUseCase = UseCaseAbstraction.createImplementation({ + implementation: DeleteModelUseCaseImpl, + dependencies: [GetModelUseCase, EventPublisher, DeleteModelRepository, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts new file mode 100644 index 00000000000..bfcbfbb96a1 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts @@ -0,0 +1,51 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import { + ModelNotAuthorizedError, + type ModelNotFoundError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; + +/** + * DeleteModel Use Case + */ +export interface IDeleteModelUseCase { + execute(modelId: string): Promise>; +} + +export interface IDeleteModelUseCaseErrors { + notFound: ModelNotFoundError; + notAuthorized: ModelNotAuthorizedError; + persistence: ModelPersistenceError; +} + +type UseCaseError = IDeleteModelUseCaseErrors[keyof IDeleteModelUseCaseErrors]; + +export const DeleteModelUseCase = createAbstraction("DeleteModelUseCase"); + +export namespace DeleteModelUseCase { + export type Interface = IDeleteModelUseCase; + export type Error = UseCaseError; +} + +/** + * DeleteModelRepository - Deletes a model from storage. + */ +export interface IDeleteModelRepository { + execute(model: CmsModel): Promise>; +} + +export interface IDeleteModelRepositoryErrors { + persistence: ModelPersistenceError; +} + +type RepositoryError = IDeleteModelRepositoryErrors[keyof IDeleteModelRepositoryErrors]; + +export const DeleteModelRepository = + createAbstraction("DeleteModelRepository"); + +export namespace DeleteModelRepository { + export type Interface = IDeleteModelRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/events.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/events.ts new file mode 100644 index 00000000000..656bc053089 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/events.ts @@ -0,0 +1,78 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsModel } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface ModelBeforeDeletePayload { + model: CmsModel; +} + +export interface ModelAfterDeletePayload { + model: CmsModel; +} + +export interface ModelDeleteErrorPayload { + model: CmsModel; + error: Error; +} + +/** + * ModelBeforeDeleteEvent - Published before deleting a model + */ +export class ModelBeforeDeleteEvent extends DomainEvent { + eventType = "Cms/Model/BeforeDelete" as const; + + getHandlerAbstraction() { + return ModelBeforeDeleteHandler; + } +} + +export const ModelBeforeDeleteHandler = createAbstraction>( + "ModelBeforeDeleteHandler" +); + +export namespace ModelBeforeDeleteHandler { + export type Interface = IEventHandler; + export type Event = ModelBeforeDeleteEvent; +} + +/** + * ModelAfterDeleteEvent - Published after deleting a model + */ +export class ModelAfterDeleteEvent extends DomainEvent { + eventType = "Cms/Model/AfterDelete" as const; + + getHandlerAbstraction() { + return ModelAfterDeleteHandler; + } +} + +export const ModelAfterDeleteHandler = + createAbstraction>("ModelAfterDeleteHandler"); + +export namespace ModelAfterDeleteHandler { + export type Interface = IEventHandler; + export type Event = ModelAfterDeleteEvent; +} + +/** + * ModelDeleteErrorEvent - Published when delete fails + */ +export class ModelDeleteErrorEvent extends DomainEvent { + eventType = "Cms/Model/DeleteError" as const; + + getHandlerAbstraction() { + return ModelDeleteErrorHandler; + } +} + +export const ModelDeleteErrorHandler = + createAbstraction>("ModelDeleteErrorHandler"); + +export namespace ModelDeleteErrorHandler { + export type Interface = IEventHandler; + export type Event = ModelDeleteErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts new file mode 100644 index 00000000000..a2576a9e716 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts @@ -0,0 +1,19 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteModelUseCase } from "./DeleteModelUseCase.js"; +import { DeleteModelRepository } from "./DeleteModelRepository.js"; + +/** + * DeleteModel Feature + * + * Provides functionality for deleting content models. + */ +export const DeleteModelFeature = createFeature({ + name: "DeleteModel", + register(container) { + // Register core use case + container.register(DeleteModelUseCase); + + // Register repository in singleton scope (shared instance) + container.register(DeleteModelRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/index.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/index.ts new file mode 100644 index 00000000000..ea4658f19ca --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/index.ts @@ -0,0 +1 @@ +export { DeleteModelUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts new file mode 100644 index 00000000000..1ed4b56294a --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts @@ -0,0 +1,99 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateModelRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; +import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { ModelValidationError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { validateEndingAllowed } from "~/crud/contentModel/validate/endingAllowed.js"; +import { validateSingularApiName } from "~/domain/contentModel/validation/singularApiName.js"; +import { validatePluralApiName } from "~/domain/contentModel/validation/pluralApiName.js"; +import { validateModelFields } from "~/domain/contentModel/validation/modelFields.js"; +import type { CmsModel } from "~/types/index.js"; + +/** + * UpdateModelRepository - Validates domain rules and persists model updates. + * + * Responsibilities: + * - Validate API name endings + * - Validate singularApiName uniqueness (excluding current model) + * - Validate pluralApiName uniqueness (excluding current model) + * - Validate model fields + * - Persist to storage + * - Clear ModelCache after successful update + */ +class UpdateModelRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private modelsFetcher: ModelsFetcher.Interface, + private storageOperations: StorageOperations.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + model: CmsModel, + original: CmsModel + ): Promise> { + try { + // Validate API name endings + try { + validateEndingAllowed({ model }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Get all models for validation (excluding the current model) + const modelsResult = await this.cmsContext.security.withoutAuthorization(async () => { + return await this.modelsFetcher.fetchAll(); + }); + + if (modelsResult.isFail()) { + return Result.fail(new ModelPersistenceError(modelsResult.error)); + } + + const allModels = modelsResult.value; + const models = allModels.filter(m => m.modelId !== model.modelId); + + // Validate uniqueness + try { + for (const existingModel of models) { + validateSingularApiName({ + existingModel, + model + }); + validatePluralApiName({ + existingModel, + model + }); + } + + // Validate model fields + await validateModelFields({ + models, + model, + original, + context: this.cmsContext + }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Persist to storage + await this.storageOperations.models.update({ model }); + + // Clear cache + this.modelCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } +} + +export const UpdateModelRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateModelRepositoryImpl, + dependencies: [ModelCache, ModelsFetcher, StorageOperations, CmsContext] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts new file mode 100644 index 00000000000..a035b45f2c5 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts @@ -0,0 +1,180 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateModelUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { UpdateModelRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { ModelBeforeUpdateEvent } from "./events.js"; +import { ModelAfterUpdateEvent } from "./events.js"; +import { ModelUpdateErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { + ModelNotAuthorizedError, + ModelPersistenceError, + ModelValidationError +} from "~/domain/contentModel/errors.js"; +import { createZodError } from "@webiny/utils"; +import { removeUndefinedValues } from "@webiny/utils"; +import { createModelUpdateValidation } from "~/domain/contentModel/schemas.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelUpdateInput } from "~/types/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; + +/** + * UpdateModelUseCase - Core model update orchestration. + * + * Responsibilities: + * - Validate input (Zod) + * - Fetch original model + * - Create updated domain model object + * - Handle group changes + * - Handle field ID changes (title, description, image) + * - Access control checks + * - Publish before event + * - Delegate to repository for validation and persistence + * - Publish after event or error event + * + * Note: Repository handles domain validations (API name uniqueness, field validation, etc.) + */ +class UpdateModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private getGroupUseCase: GetGroupUseCase.Interface, + private eventPublisher: EventPublisher.Interface, + private repository: UpdateModelRepository.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + modelId: string, + input: CmsModelUpdateInput + ): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessModel({ rwd: "w" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Get the original model (with access control check) + const getResult = await this.getModelUseCase.execute(modelId); + if (getResult.isFail()) { + return getResult; + } + + const original = structuredClone(getResult.value); + + // Validate input + const validationResult = await createModelUpdateValidation().safeParseAsync(input); + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new ModelValidationError(zodError.message)); + } + + const data = removeUndefinedValues(validationResult.data); + + // If no changes, return original + if (Object.keys(data).length === 0) { + return Result.ok(original); + } + + // Handle group changes + let group = { + id: original.group.id, + name: original.group.name + }; + + if (data.group) { + const groupResult = await this.getGroupUseCase.execute(data.group); + + if (groupResult.isFail()) { + const error = groupResult.error; + if (error.code === "Cms/ModelGroup/PersistenceError") { + return Result.fail(new ModelPersistenceError(error)); + } + + return Result.fail(error); + } + + const groupData = groupResult.value; + group = { + id: groupData.id, + name: groupData.name + }; + } + + // Create updated model + const tenant = this.tenantContext.getTenant(); + + const model: CmsModel = { + ...original, + ...data, + // Handle optional field IDs explicitly + titleFieldId: + data.titleFieldId === undefined + ? original.titleFieldId + : (data.titleFieldId as string), + descriptionFieldId: + data.descriptionFieldId === undefined + ? original.descriptionFieldId + : data.descriptionFieldId, + imageFieldId: + data.imageFieldId === undefined ? original.imageFieldId : data.imageFieldId, + group, + description: data.description || original.description, + tenant: original.tenant || tenant.id, + webinyVersion: this.cmsContext.WEBINY_VERSION, + savedOn: new Date().toISOString() + }; + + // Access control check on the updated model + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "w" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + // Ensure type tags + model.tags = ensureTypeTag(model); + + // Publish before event + await this.eventPublisher.publish( + new ModelBeforeUpdateEvent({ model, original, input: data }) + ); + + // Persist via repository (repository will validate) + const result = await this.repository.execute(model, original); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new ModelUpdateErrorEvent({ + input: data, + model, + original, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish(new ModelAfterUpdateEvent({ model, original })); + + return Result.ok(model); + } +} + +export const UpdateModelUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateModelUseCaseImpl, + dependencies: [ + GetModelUseCase, + GetGroupUseCase, + EventPublisher, + UpdateModelRepository, + AccessControl, + TenantContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts new file mode 100644 index 00000000000..67a5d50570e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts @@ -0,0 +1,64 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelUpdateInput } from "~/types/index.js"; +import { + type ModelSlugTakenError, + ModelNotAuthorizedError, + type ModelNotFoundError, + type ModelValidationError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; +import { + type GroupNotFoundError, + type GroupNotAuthorizedError +} from "~/domain/contentModelGroup/errors.js"; + +/** + * UpdateModel Use Case + */ +export interface IUpdateModelUseCase { + execute(modelId: string, input: CmsModelUpdateInput): Promise>; +} + +export interface IUpdateModelUseCaseErrors { + notFound: ModelNotFoundError; + notAuthorized: ModelNotAuthorizedError; + validation: ModelValidationError; + alreadyExists: ModelSlugTakenError; + persistence: ModelPersistenceError; + groupNotFound: GroupNotFoundError; + groupNotAccessible: GroupNotAuthorizedError; +} + +type UseCaseError = IUpdateModelUseCaseErrors[keyof IUpdateModelUseCaseErrors]; + +export const UpdateModelUseCase = createAbstraction("UpdateModelUseCase"); + +export namespace UpdateModelUseCase { + export type Interface = IUpdateModelUseCase; + export type Error = UseCaseError; +} + +/** + * UpdateModelRepository - Validates domain rules and persists model updates. + */ +export interface IUpdateModelRepository { + execute(model: CmsModel, original: CmsModel): Promise>; +} + +export interface IUpdateModelRepositoryErrors { + alreadyExists: ModelSlugTakenError; + validation: ModelValidationError; + persistence: ModelPersistenceError; +} + +type RepositoryError = IUpdateModelRepositoryErrors[keyof IUpdateModelRepositoryErrors]; + +export const UpdateModelRepository = + createAbstraction("UpdateModelRepository"); + +export namespace UpdateModelRepository { + export type Interface = IUpdateModelRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/events.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/events.ts new file mode 100644 index 00000000000..fd8968222b3 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/events.ts @@ -0,0 +1,84 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelUpdateInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface ModelBeforeUpdatePayload { + model: CmsModel; + original: CmsModel; + input: CmsModelUpdateInput; +} + +export interface ModelAfterUpdatePayload { + model: CmsModel; + original: CmsModel; +} + +export interface ModelUpdateErrorPayload { + input: CmsModelUpdateInput; + model: CmsModel; + original: CmsModel; + error: Error; +} + +/** + * ModelBeforeUpdateEvent - Published before updating a model + */ +export class ModelBeforeUpdateEvent extends DomainEvent { + eventType = "Cms/Model/BeforeUpdate" as const; + + getHandlerAbstraction() { + return ModelBeforeUpdateHandler; + } +} + +export const ModelBeforeUpdateHandler = createAbstraction>( + "ModelBeforeUpdateHandler" +); + +export namespace ModelBeforeUpdateHandler { + export type Interface = IEventHandler; + export type Event = ModelBeforeUpdateEvent; +} + +/** + * ModelAfterUpdateEvent - Published after updating a model + */ +export class ModelAfterUpdateEvent extends DomainEvent { + eventType = "Cms/Model/AfterUpdate" as const; + + getHandlerAbstraction() { + return ModelAfterUpdateHandler; + } +} + +export const ModelAfterUpdateHandler = + createAbstraction>("ModelAfterUpdateHandler"); + +export namespace ModelAfterUpdateHandler { + export type Interface = IEventHandler; + export type Event = ModelAfterUpdateEvent; +} + +/** + * ModelUpdateErrorEvent - Published when update fails + */ +export class ModelUpdateErrorEvent extends DomainEvent { + eventType = "Cms/Model/UpdateError" as const; + + getHandlerAbstraction() { + return ModelUpdateErrorHandler; + } +} + +export const ModelUpdateErrorHandler = + createAbstraction>("ModelUpdateErrorHandler"); + +export namespace ModelUpdateErrorHandler { + export type Interface = IEventHandler; + export type Event = ModelUpdateErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/feature.ts new file mode 100644 index 00000000000..2c6d020c916 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateModelUseCase } from "./UpdateModelUseCase.js"; +import { UpdateModelRepository } from "./UpdateModelRepository.js"; + +/** + * UpdateModel Feature + * + * Provides functionality for updating existing content models. + * All validation (API name uniqueness, field validation) is handled by the repository. + */ +export const UpdateModelFeature = createFeature({ + name: "UpdateModel", + register(container) { + // Register core use case + container.register(UpdateModelUseCase); + + // Register repository in singleton scope (shared instance) + container.register(UpdateModelRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/index.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/index.ts new file mode 100644 index 00000000000..cb8e3fe357c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/index.ts @@ -0,0 +1 @@ +export { UpdateModelUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/types/model.ts b/packages/api-headless-cms/src/types/model.ts index 790a5bcf01e..6ee8586c19e 100644 --- a/packages/api-headless-cms/src/types/model.ts +++ b/packages/api-headless-cms/src/types/model.ts @@ -1,5 +1,5 @@ import type { CmsIdentity } from "./identity.js"; -import type { CmsModelField, CmsModelFieldInput, LockedField } from "./modelField.js"; +import type { CmsModelField, CmsModelFieldInput } from "./modelField.js"; import type { CmsModelGroup } from "./modelGroup.js"; /** @@ -82,10 +82,6 @@ export interface CmsModel { * Models can be tagged to give them contextual meaning. */ tags?: string[]; - /** - * List of locked fields. Updated when entry is saved and a field has been used. - */ - lockedFields?: LockedField[]; /** * The field that is used as an entry title. * If not specified by the user, the system tries to assign the first available `text` field. diff --git a/packages/api-headless-cms/src/types/modelField.ts b/packages/api-headless-cms/src/types/modelField.ts index 4cd07a43f48..e768418d996 100644 --- a/packages/api-headless-cms/src/types/modelField.ts +++ b/packages/api-headless-cms/src/types/modelField.ts @@ -225,31 +225,6 @@ export interface CmsModelUpdateInput { imageFieldId?: string | null; } -/** - * Locked field in the content model - * - * @see CmsModel.lockedFields - * - * @category ModelField - */ -export interface LockedField { - /** - * Locked field storage ID - one used to store values. - * We cannot change this due to old systems. - */ - fieldId: string; - /** - * Is the field multiple values field? - */ - multipleValues: boolean; - /** - * Field type. - */ - type: string; - - [key: string]: any; -} - /** * Object containing content model field renderer options. * diff --git a/packages/api-headless-cms/src/types/plugins.ts b/packages/api-headless-cms/src/types/plugins.ts index 586acb8dd4a..f7b2103b761 100644 --- a/packages/api-headless-cms/src/types/plugins.ts +++ b/packages/api-headless-cms/src/types/plugins.ts @@ -10,7 +10,7 @@ import type { CmsModelFieldValidatorValidateParams } from "./types.js"; import type { GetCmsModelFieldAst } from "./modelAst.js"; -import type { CmsModelField, CmsModelFieldType, LockedField } from "./modelField.js"; +import type { CmsModelField, CmsModelFieldType } from "./modelField.js"; import type { CmsModel } from "./model.js"; /** @@ -281,32 +281,6 @@ export interface CmsModelFieldToGraphQLPlugin; } -/** - * Check for content model locked field. - * A custom plugin definable by the user. - * - * @category CmsModel - * @category Plugin - */ -export interface CmsModelLockedFieldPlugin extends Plugin { - /** - * A plugin type - */ - type: "cms-model-locked-field"; - /** - * A unique identifier of the field type (text, number, json, myField, ...). - */ - fieldType: string; - /** - * A method to check if field really is locked. - */ - checkLockedField?: (params: { lockedField: LockedField; field: CmsModelField }) => void; - /** - * A method to get the locked field data. - */ - getLockedFieldData?: (params: { field: CmsModelField }) => Record; -} - /** * Definition for the field validator. * From e72acfe3303f4f615811b3021a39811b48b24ceb Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 12:09:12 +0100 Subject: [PATCH 26/71] wip: migrate content model crud --- packages/api-headless-cms/src/context.ts | 7 - .../src/crud/contentModel.crud.ts | 172 +---------------- .../contentModel/ContentModelFeature.ts | 2 + .../CreateModelFromRepository.ts | 182 ++++++++++++++++++ .../CreateModelFrom/CreateModelFromUseCase.ts | 169 ++++++++++++++++ .../CreateModelFrom/abstractions.ts | 70 +++++++ .../contentModel/CreateModelFrom/events.ts | 86 +++++++++ .../contentModel/CreateModelFrom/feature.ts | 20 ++ .../contentModel/CreateModelFrom/index.ts | 1 + packages/api-headless-cms/src/types/types.ts | 5 - 10 files changed, 539 insertions(+), 175 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/CreateModelFrom/index.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index de3d8a5b11b..ef282182aea 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -55,10 +55,6 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { const plugin = new ContextPlugin(async context => { const { type } = await getParameters(context); - const getIdentity = () => { - return context.security.getIdentity(); - }; - const getTenant = () => { return context.tenancy.getCurrentTenant(); }; @@ -134,9 +130,6 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { }), ...createModelsCrud({ context, - getTenant, - getIdentity, - storageOperations, accessControl }), ...createContentEntryCrud({ diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index 419d8ee6349..b2e519c53b4 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -1,13 +1,9 @@ import WebinyError from "@webiny/error"; import type { CmsContext, - CmsEntryValues, CmsModel, CmsModelContext, CmsModelFieldToGraphQLPlugin, - CmsModelManager, - CmsModelUpdateInput, - HeadlessCmsStorageOperations, ICmsModelListParams, OnModelAfterCreateFromTopicParams, OnModelAfterCreateTopicParams, @@ -23,12 +19,9 @@ import type { OnModelInitializeParams, OnModelUpdateErrorTopicParams } from "~/types/index.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { contentModelManagerFactory } from "./contentModel/contentModelManagerFactory.js"; import { createTopic } from "@webiny/pubsub"; -import { createModelCreateFromValidation } from "~/crud/contentModel/validation.js"; -import { createZodError, removeUndefinedValues } from "@webiny/utils"; import { CreateModelUseCase } from "~/features/contentModel/CreateModel/index.js"; +import { CreateModelFromUseCase } from "~/features/contentModel/CreateModelFrom/index.js"; import { UpdateModelUseCase } from "~/features/contentModel/UpdateModel/index.js"; import { DeleteModelUseCase } from "~/features/contentModel/DeleteModel/index.js"; import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; @@ -39,19 +32,14 @@ import { CmsModelFieldToAstConverterFromPlugins, CmsModelToAstConverter } from "~/utils/contentModelAst/index.js"; -import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; export interface CreateModelsCrudParams { - getTenant: () => Tenant; - storageOperations: HeadlessCmsStorageOperations; accessControl: AccessControl; context: CmsContext; - getIdentity: () => SecurityIdentity; } export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContext => { - const { getTenant, getIdentity, storageOperations, accessControl, context } = params; + const { accessControl, context } = params; const listFilteredModelsCache = createMemoryCache>(); const listDatabaseModelsCache = createMemoryCache>(); @@ -60,16 +48,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex listFilteredModelsCache.clear(); }; - const managers = new Map(); - const updateManager = async ( - context: CmsContext, - model: CmsModel - ): Promise> => { - const manager = await contentModelManagerFactory(context, model); - managers.set(model.modelId, manager); - return manager; - }; - const fieldTypePlugins = context.plugins.byType( "cms-model-field-to-graphql" ); @@ -175,137 +153,19 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex throw new WebinyError(result.error.message, result.error.code, result.error.data); } - // Update manager after successful update - await updateManager(context, result.value); - return result.value; }; - const updateModelDirect: CmsModelContext["updateModelDirect"] = async params => { - const { model: initialModel, original } = params; - - const model: CmsModel = { - ...initialModel, - tenant: initialModel.tenant || getTenant().id, - webinyVersion: context.WEBINY_VERSION - }; - try { - await onModelBeforeUpdate.publish({ - input: {} as CmsModelUpdateInput, - original, - model - }); - - const resultModel = await storageOperations.models.update({ - model - }); - - await updateManager(context, resultModel); - - clearModelsCache(); - - await onModelAfterUpdate.publish({ - input: {} as CmsModelUpdateInput, - original, - model: resultModel - }); - - return resultModel; - } catch (ex) { - await onModelUpdateError.publish({ - input: {} as CmsModelUpdateInput, - original, - model, - error: ex - }); - throw ex; - } - }; const createModelFrom: CmsModelContext["createModelFrom"] = async (modelId, input) => { - await accessControl.ensureCanAccessModel({ rwd: "w" }); - - /** - * Get a model record; this will also perform ownership validation. - */ - const original = await getModel(modelId); - - const result = await createModelCreateFromValidation().safeParseAsync({ - ...input, - description: input.description || original.description - }); - if (!result.success) { - throw createZodError(result.error); - } - - const data = removeUndefinedValues(result.data); + // Delegate to new CreateModelFrom use case + const useCase = context.container.resolve(CreateModelFromUseCase); + const result = await useCase.execute(modelId, input); - /** - * Use storage operations directly because we cannot get group from different locale via context methods. - */ - const group = await context.cms.storageOperations.groups.get({ - id: data.group, - tenant: original.tenant - }); - if (!group) { - throw new NotFoundError(`There is no group "${data.group}".`); + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); } - const identity = getIdentity(); - const model: CmsModel = { - ...original, - singularApiName: data.singularApiName, - pluralApiName: data.pluralApiName, - group: { - id: group.id, - name: group.name - }, - icon: data.icon, - name: data.name, - modelId: data.modelId || "", - description: data.description || "", - createdBy: { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }, - createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION - }; - - await accessControl.ensureCanAccessModel({ model, rwd: "w" }); - - try { - await onModelBeforeCreateFrom.publish({ - input: data, - model, - original - }); - - const createdModel = await storageOperations.models.create({ - model - }); - - clearModelsCache(); - - await updateManager(context, model); - - await onModelAfterCreateFrom.publish({ - input: data, - original, - model: createdModel - }); - - return createdModel; - } catch (ex) { - await onModelCreateFromError.publish({ - input: data, - original, - model, - error: ex - }); - throw ex; - } + return result.value; }; const deleteModel: CmsModelContext["deleteModel"] = async modelId => { // Delegate to new DeleteModel use case @@ -315,9 +175,6 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex if (result.isFail()) { throw new WebinyError(result.error.message, result.error.code, result.error.data); } - - // Clean up manager after successful deletion - managers.delete(modelId); }; const initializeModel: CmsModelContext["initializeModel"] = async (modelId, data) => { /** @@ -355,18 +212,7 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex return createModel(input); }); }, - /** - * Method does not check for permissions or ownership. - * @internal - */ - async updateModelDirect(params) { - return context.benchmark.measure( - "headlessCms.crud.models.updateModelDirect", - async () => { - return updateModelDirect(params); - } - ); - }, + async createModelFrom(modelId, userInput) { return context.benchmark.measure( "headlessCms.crud.models.createModelFrom", diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index 184c2d7cae5..32fc8c28772 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -5,6 +5,7 @@ import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; import { ModelsFetcher } from "~/features/contentModel/shared/ModelsFetcher.js"; import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; +import { CreateModelFromFeature } from "~/features/contentModel/CreateModelFrom/feature.js"; import { UpdateModelFeature } from "~/features/contentModel/UpdateModel/feature.js"; import { DeleteModelFeature } from "~/features/contentModel/DeleteModel/feature.js"; import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; @@ -24,6 +25,7 @@ export const ContentModelFeature = createFeature({ // Command features CreateModelFeature.register(container); + CreateModelFromFeature.register(container); UpdateModelFeature.register(container); DeleteModelFeature.register(container); } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts new file mode 100644 index 00000000000..f330e375618 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts @@ -0,0 +1,182 @@ +import camelCase from "lodash/camelCase.js"; +import { Result } from "@webiny/feature/api"; +import { CreateModelFromRepository as RepositoryAbstraction } from "./abstractions.js"; +import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; +import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; +import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; +import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { ModelValidationError } from "~/domain/contentModel/errors.js"; +import { StorageOperations } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { + validateExistingModelId, + validateModelIdAllowed +} from "~/crud/contentModel/validate/modelId.js"; +import { validateEndingAllowed } from "~/crud/contentModel/validate/endingAllowed.js"; +import { validateSingularApiName } from "~/domain/contentModel/validation/singularApiName.js"; +import { validatePluralApiName } from "~/domain/contentModel/validation/pluralApiName.js"; +import { validateModelFields } from "~/domain/contentModel/validation/modelFields.js"; +import type { CmsModel } from "~/types/index.js"; +import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; + +/** + * Generate modelId from model following the exact logic from beforeCreate.ts + */ +const getModelId = (model: { modelId?: string; name?: string }): string => { + const { modelId, name } = model; + const value = modelId ? modelId.trim() : null; + if (value) { + const isModelIdValid = camelCase(value).toLowerCase() === value.toLowerCase(); + if (isModelIdValid) { + return value; + } + return camelCase(value); + } else if (name) { + return camelCase(name.trim()); + } + throw new ModelValidationError( + `There is no "modelId" or "name" passed into the create model from method.` + ); +}; + +/** + * CreateModelFromRepository - Validates domain rules and persists cloned model. + * + * Responsibilities: + * - Generate modelId from input + * - Validate modelId is allowed (not in disallowed list) + * - Validate API name endings + * - Validate modelId uniqueness (database + plugins) + * - Validate API name uniqueness (database + plugins) + * - Validate plugin conflicts + * - Validate model fields + * - Persist to storage + * - Clear ModelCache after successful create + */ +class CreateModelFromRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private modelCache: ModelCache.Interface, + private pluginModelsProvider: PluginModelsProvider.Interface, + private modelsFetcher: ModelsFetcher.Interface, + private storageOperations: StorageOperations.Interface, + private tenantContext: TenantContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute(model: CmsModel): Promise> { + try { + const tenant = this.tenantContext.getTenant(); + + // Generate modelId using the exact logic from beforeCreate.ts + const modelId = getModelId(model); + model.modelId = modelId; + + // Validate modelId is allowed (not in disallowed list) + try { + validateModelIdAllowed({ model }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Validate API name endings + try { + validateEndingAllowed({ model }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Validate modelId uniqueness (database) + const existingById = await this.storageOperations.models.list({ + where: { + tenant: tenant.id, + modelId: model.modelId + } + }); + + if (existingById.length > 0) { + return Result.fail(new ModelSlugTakenError(model.modelId)); + } + + // Check for plugin model conflicts + const pluginModels = await this.pluginModelsProvider.list(tenant.id); + const pluginModelConflict = pluginModels.find(pm => { + return ( + pm.modelId === model.modelId || + pm.singularApiName === model.singularApiName || + pm.pluralApiName === model.pluralApiName + ); + }); + + if (pluginModelConflict) { + return Result.fail( + new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) + ); + } + + // Get all models for further validation + const modelsResult = await this.cmsContext.security.withoutAuthorization(async () => { + return await this.modelsFetcher.fetchAll(); + }); + + if (modelsResult.isFail()) { + return Result.fail(new ModelPersistenceError(modelsResult.error)); + } + + const models = modelsResult.value; + + try { + // Validate uniqueness + for (const existingModel of models) { + validateExistingModelId({ + existingModel, + model + }); + validateSingularApiName({ + existingModel, + model + }); + validatePluralApiName({ + existingModel, + model + }); + } + + // Validate model fields + await validateModelFields({ + models, + model, + context: this.cmsContext + }); + } catch (error) { + return Result.fail(new ModelValidationError((error as Error).message)); + } + + // Ensure type tags + model.tags = ensureTypeTag(model); + + // Persist to storage + await this.storageOperations.models.create({ model }); + + // Clear cache + this.modelCache.clear(); + + return Result.ok(); + } catch (error) { + return Result.fail(new ModelPersistenceError(error as Error)); + } + } +} + +export const CreateModelFromRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateModelFromRepositoryImpl, + dependencies: [ + ModelCache, + PluginModelsProvider, + ModelsFetcher, + StorageOperations, + TenantContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts new file mode 100644 index 00000000000..e04ccf5f756 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts @@ -0,0 +1,169 @@ +import { Result } from "@webiny/feature/api"; +import { CreateModelFromUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CreateModelFromRepository } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { ModelBeforeCreateFromEvent } from "./events.js"; +import { ModelAfterCreateFromEvent } from "./events.js"; +import { ModelCreateFromErrorEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { + ModelNotAuthorizedError, + ModelPersistenceError, + ModelValidationError +} from "~/domain/contentModel/errors.js"; +import { createZodError } from "@webiny/utils"; +import { removeUndefinedValues } from "@webiny/utils"; +import { createModelCreateFromValidation } from "~/domain/contentModel/schemas.js"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateFromInput } from "~/types/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { GetGroupUseCase } from "~/features/contentModelGroup/GetGroup/index.js"; + +/** + * CreateModelFromUseCase - Clone/copy a model from existing model. + * + * Responsibilities: + * - Validate input (Zod) + * - Fetch original model + * - Create new model preserving fields and layout + * - Handle group changes + * - Access control checks + * - Publish before event + * - Delegate to repository for validation and persistence + * - Publish after event or error event + * + * Note: Repository handles domain validations (modelId generation, uniqueness, etc.) + */ +class CreateModelFromUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private getGroupUseCase: GetGroupUseCase.Interface, + private eventPublisher: EventPublisher.Interface, + private repository: CreateModelFromRepository.Interface, + private accessControl: AccessControl.Interface, + private tenantContext: TenantContext.Interface, + private identityContext: IdentityContext.Interface, + private cmsContext: CmsContext.Interface + ) {} + + async execute( + modelId: string, + input: CmsModelCreateFromInput + ): Promise> { + // Initial access control check + const canAccess = await this.accessControl.canAccessModel({ rwd: "w" }); + if (!canAccess) { + return Result.fail(new ModelNotAuthorizedError()); + } + + // Get the original model (with access control check) + const getResult = await this.getModelUseCase.execute(modelId); + if (getResult.isFail()) { + return getResult; + } + + const original = getResult.value; + + // Validate input (merge with original description if not provided) + const validationResult = await createModelCreateFromValidation().safeParseAsync({ + ...input, + description: input.description || original.description + }); + + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new ModelValidationError(zodError.message)); + } + + const data = removeUndefinedValues(validationResult.data); + + // Resolve group + const groupResult = await this.getGroupUseCase.execute(data.group); + + if (groupResult.isFail()) { + const error = groupResult.error; + if (error.code === "Cms/ModelGroup/PersistenceError") { + return Result.fail(new ModelPersistenceError(error)); + } + + return Result.fail(error); + } + + const group = groupResult.value; + + // Create new model from original (preserve fields and layout) + const identity = this.identityContext.getIdentity(); + const tenant = this.tenantContext.getTenant(); + + const model: CmsModel = { + ...original, + singularApiName: data.singularApiName, + pluralApiName: data.pluralApiName, + group: { + id: group.id, + name: group.name + }, + icon: data.icon, + name: data.name, + modelId: data.modelId || "", // Will be set by repository + description: data.description || "", + createdBy: { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }, + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + tenant: original.tenant || tenant.id, + webinyVersion: this.cmsContext.WEBINY_VERSION + }; + + // Access control check on the new model + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "w" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + // Publish before event + await this.eventPublisher.publish( + new ModelBeforeCreateFromEvent({ model, original, input: data }) + ); + + // Persist via repository (repository will validate and set modelId) + const result = await this.repository.execute(model); + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish( + new ModelCreateFromErrorEvent({ + input: data, + model, + original, + error: result.error + }) + ); + return Result.fail(result.error); + } + + // Publish after event + await this.eventPublisher.publish(new ModelAfterCreateFromEvent({ model, original })); + + return Result.ok(model); + } +} + +export const CreateModelFromUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateModelFromUseCaseImpl, + dependencies: [ + GetModelUseCase, + GetGroupUseCase, + EventPublisher, + CreateModelFromRepository, + AccessControl, + TenantContext, + IdentityContext, + CmsContext + ] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts new file mode 100644 index 00000000000..2054fff1028 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts @@ -0,0 +1,70 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateFromInput } from "~/types/index.js"; +import { + type ModelSlugTakenError, + ModelNotAuthorizedError, + type ModelNotFoundError, + type ModelValidationError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; +import { + type GroupNotFoundError, + type GroupNotAuthorizedError +} from "~/domain/contentModelGroup/errors.js"; + +/** + * CreateModelFrom Use Case (Clone/Copy Model) + */ +export interface ICreateModelFromUseCase { + execute( + modelId: string, + input: CmsModelCreateFromInput + ): Promise>; +} + +export interface ICreateModelFromUseCaseErrors { + notFound: ModelNotFoundError; + notAuthorized: ModelNotAuthorizedError; + validation: ModelValidationError; + alreadyExists: ModelSlugTakenError; + persistence: ModelPersistenceError; + groupNotFound: GroupNotFoundError; + groupNotAccessible: GroupNotAuthorizedError; +} + +type UseCaseError = ICreateModelFromUseCaseErrors[keyof ICreateModelFromUseCaseErrors]; + +export const CreateModelFromUseCase = createAbstraction( + "CreateModelFromUseCase" +); + +export namespace CreateModelFromUseCase { + export type Interface = ICreateModelFromUseCase; + export type Error = UseCaseError; +} + +/** + * CreateModelFromRepository - Validates domain rules and persists cloned model. + */ +export interface ICreateModelFromRepository { + execute(model: CmsModel): Promise>; +} + +export interface ICreateModelFromRepositoryErrors { + alreadyExists: ModelSlugTakenError; + validation: ModelValidationError; + persistence: ModelPersistenceError; +} + +type RepositoryError = ICreateModelFromRepositoryErrors[keyof ICreateModelFromRepositoryErrors]; + +export const CreateModelFromRepository = createAbstraction( + "CreateModelFromRepository" +); + +export namespace CreateModelFromRepository { + export type Interface = ICreateModelFromRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/events.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/events.ts new file mode 100644 index 00000000000..fd672cab817 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/events.ts @@ -0,0 +1,86 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsModel } from "~/types/index.js"; +import type { CmsModelCreateFromInput } from "~/types/index.js"; + +/** + * Event payloads + */ +export interface ModelBeforeCreateFromPayload { + model: CmsModel; + original: CmsModel; + input: CmsModelCreateFromInput; +} + +export interface ModelAfterCreateFromPayload { + model: CmsModel; + original: CmsModel; +} + +export interface ModelCreateFromErrorPayload { + input: CmsModelCreateFromInput; + model: CmsModel; + original: CmsModel; + error: Error; +} + +/** + * ModelBeforeCreateFromEvent - Published before creating a model from existing + */ +export class ModelBeforeCreateFromEvent extends DomainEvent { + eventType = "Cms/Model/BeforeCreateFrom" as const; + + getHandlerAbstraction() { + return ModelBeforeCreateFromHandler; + } +} + +export const ModelBeforeCreateFromHandler = createAbstraction< + IEventHandler +>("ModelBeforeCreateFromHandler"); + +export namespace ModelBeforeCreateFromHandler { + export type Interface = IEventHandler; + export type Event = ModelBeforeCreateFromEvent; +} + +/** + * ModelAfterCreateFromEvent - Published after creating a model from existing + */ +export class ModelAfterCreateFromEvent extends DomainEvent { + eventType = "Cms/Model/AfterCreateFrom" as const; + + getHandlerAbstraction() { + return ModelAfterCreateFromHandler; + } +} + +export const ModelAfterCreateFromHandler = createAbstraction< + IEventHandler +>("ModelAfterCreateFromHandler"); + +export namespace ModelAfterCreateFromHandler { + export type Interface = IEventHandler; + export type Event = ModelAfterCreateFromEvent; +} + +/** + * ModelCreateFromErrorEvent - Published when create from fails + */ +export class ModelCreateFromErrorEvent extends DomainEvent { + eventType = "Cms/Model/CreateFromError" as const; + + getHandlerAbstraction() { + return ModelCreateFromErrorHandler; + } +} + +export const ModelCreateFromErrorHandler = createAbstraction< + IEventHandler +>("ModelCreateFromErrorHandler"); + +export namespace ModelCreateFromErrorHandler { + export type Interface = IEventHandler; + export type Event = ModelCreateFromErrorEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/feature.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/feature.ts new file mode 100644 index 00000000000..40416299983 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateModelFromUseCase } from "./CreateModelFromUseCase.js"; +import { CreateModelFromRepository } from "./CreateModelFromRepository.js"; + +/** + * CreateModelFrom Feature + * + * Provides functionality for cloning/copying existing content models. + * All validation (modelId generation, domain rules, uniqueness) is handled by the repository. + */ +export const CreateModelFromFeature = createFeature({ + name: "CreateModelFrom", + register(container) { + // Register core use case + container.register(CreateModelFromUseCase); + + // Register repository in singleton scope (shared instance) + container.register(CreateModelFromRepository).inSingletonScope(); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/index.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/index.ts new file mode 100644 index 00000000000..38eb6055b90 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/index.ts @@ -0,0 +1 @@ +export { CreateModelFromUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 0ccd032331f..1480d6e101c 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -841,11 +841,6 @@ export interface CmsModelContext { * Create a content model from the given model - clone. */ createModelFrom(modelId: string, data: CmsModelCreateFromInput): Promise; - /** - * Update content model without data validation. Used internally. - * @hidden - */ - updateModelDirect(params: CmsModelUpdateDirectParams): Promise; /** * Update content model. */ From 26bf9ccbecd31b962dfc8126587800648ec53473 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 12:14:38 +0100 Subject: [PATCH 27/71] wip: migrate content model crud --- packages/api-headless-cms/src/context.ts | 3 +- .../src/crud/contentModel.crud.ts | 19 +++---- .../contentModel/ContentModelFeature.ts | 2 + .../InitializeModel/InitializeModelUseCase.ts | 55 +++++++++++++++++++ .../InitializeModel/abstractions.ts | 31 +++++++++++ .../contentModel/InitializeModel/events.ts | 34 ++++++++++++ .../contentModel/InitializeModel/feature.ts | 17 ++++++ .../contentModel/InitializeModel/index.ts | 1 + 8 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 packages/api-headless-cms/src/features/contentModel/InitializeModel/InitializeModelUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/InitializeModel/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/InitializeModel/index.ts diff --git a/packages/api-headless-cms/src/context.ts b/packages/api-headless-cms/src/context.ts index ef282182aea..5dc4c1441cf 100644 --- a/packages/api-headless-cms/src/context.ts +++ b/packages/api-headless-cms/src/context.ts @@ -129,8 +129,7 @@ export const createContextPlugin = ({ storageOperations }: CrudParams) => { context }), ...createModelsCrud({ - context, - accessControl + context }), ...createContentEntryCrud({ context diff --git a/packages/api-headless-cms/src/crud/contentModel.crud.ts b/packages/api-headless-cms/src/crud/contentModel.crud.ts index b2e519c53b4..d7553e273b7 100644 --- a/packages/api-headless-cms/src/crud/contentModel.crud.ts +++ b/packages/api-headless-cms/src/crud/contentModel.crud.ts @@ -24,22 +24,21 @@ import { CreateModelUseCase } from "~/features/contentModel/CreateModel/index.js import { CreateModelFromUseCase } from "~/features/contentModel/CreateModelFrom/index.js"; import { UpdateModelUseCase } from "~/features/contentModel/UpdateModel/index.js"; import { DeleteModelUseCase } from "~/features/contentModel/DeleteModel/index.js"; +import { InitializeModelUseCase } from "~/features/contentModel/InitializeModel/index.js"; import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; import { createMemoryCache } from "~/utils/index.js"; -import type { AccessControl } from "./AccessControl/AccessControl.js"; import { CmsModelFieldToAstConverterFromPlugins, CmsModelToAstConverter } from "~/utils/contentModelAst/index.js"; export interface CreateModelsCrudParams { - accessControl: AccessControl; context: CmsContext; } export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContext => { - const { accessControl, context } = params; + const { context } = params; const listFilteredModelsCache = createMemoryCache>(); const listDatabaseModelsCache = createMemoryCache>(); @@ -177,15 +176,13 @@ export const createModelsCrud = (params: CreateModelsCrudParams): CmsModelContex } }; const initializeModel: CmsModelContext["initializeModel"] = async (modelId, data) => { - /** - * We require that users have write permissions to initialize models. - * Maybe introduce another permission for it? - */ - const model = await getModel(modelId); + // Delegate to new InitializeModel use case + const useCase = context.container.resolve(InitializeModelUseCase); + const result = await useCase.execute(modelId, data); - await accessControl.ensureCanAccessModel({ model, rwd: "w" }); - - await onModelInitialize.publish({ model, data }); + if (result.isFail()) { + throw new WebinyError(result.error.message, result.error.code, result.error.data); + } return true; }; diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index 32fc8c28772..3df407126de 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -8,6 +8,7 @@ import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature. import { CreateModelFromFeature } from "~/features/contentModel/CreateModelFrom/feature.js"; import { UpdateModelFeature } from "~/features/contentModel/UpdateModel/feature.js"; import { DeleteModelFeature } from "~/features/contentModel/DeleteModel/feature.js"; +import { InitializeModelFeature } from "~/features/contentModel/InitializeModel/feature.js"; import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; import { ListModelsFeature } from "~/features/contentModel/ListModels/feature.js"; @@ -28,5 +29,6 @@ export const ContentModelFeature = createFeature({ CreateModelFromFeature.register(container); UpdateModelFeature.register(container); DeleteModelFeature.register(container); + InitializeModelFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/InitializeModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/InitializeModelUseCase.ts new file mode 100644 index 00000000000..d216a5e73e9 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/InitializeModelUseCase.ts @@ -0,0 +1,55 @@ +import { Result } from "@webiny/feature/api"; +import { InitializeModelUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { ModelInitializeEvent } from "./events.js"; +import { AccessControl } from "~/features/shared/abstractions.js"; +import { ModelNotAuthorizedError } from "~/domain/contentModel/errors.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; + +/** + * InitializeModelUseCase - Initialize model with data. + * + * Responsibilities: + * - Fetch model + * - Access control checks (using write permission) + * - Publish initialize event + * - Event handlers can initialize model data (e.g., create default entries) + * + * Note: This is primarily an event dispatch mechanism for plugins + */ +class InitializeModelUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private eventPublisher: EventPublisher.Interface, + private accessControl: AccessControl.Interface + ) {} + + async execute( + modelId: string, + data?: Record + ): Promise> { + // Get the model (with access control check) + const getResult = await this.getModelUseCase.execute(modelId); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + + const model = getResult.value; + + // Access control check (using write permission) + const canAccessModel = await this.accessControl.canAccessModel({ model, rwd: "w" }); + if (!canAccessModel) { + return Result.fail(ModelNotAuthorizedError.fromModel(model)); + } + + // Publish initialize event for plugins to handle + await this.eventPublisher.publish(new ModelInitializeEvent({ model, data })); + + return Result.ok(); + } +} + +export const InitializeModelUseCase = UseCaseAbstraction.createImplementation({ + implementation: InitializeModelUseCaseImpl, + dependencies: [GetModelUseCase, EventPublisher, AccessControl] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts new file mode 100644 index 00000000000..914f7b39bba --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts @@ -0,0 +1,31 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { + ModelNotAuthorizedError, + type ModelNotFoundError, + type ModelPersistenceError +} from "~/domain/contentModel/errors.js"; + +/** + * InitializeModel Use Case + */ +export interface IInitializeModelUseCase { + execute(modelId: string, data?: Record): Promise>; +} + +export interface IInitializeModelUseCaseErrors { + notFound: ModelNotFoundError; + notAuthorized: ModelNotAuthorizedError; + persistence: ModelPersistenceError; +} + +type UseCaseError = IInitializeModelUseCaseErrors[keyof IInitializeModelUseCaseErrors]; + +export const InitializeModelUseCase = createAbstraction( + "InitializeModelUseCase" +); + +export namespace InitializeModelUseCase { + export type Interface = IInitializeModelUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts new file mode 100644 index 00000000000..5276f3d3e39 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts @@ -0,0 +1,34 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { CmsModel } from "~/types/index.js"; + +/** + * Event payload + */ +export interface ModelInitializePayload { + model: CmsModel; + data?: Record; +} + +/** + * ModelInitializeEvent - Published when initializing a model + * + * This event allows plugins to initialize model data (e.g., create default entries) + */ +export class ModelInitializeEvent extends DomainEvent { + eventType = "Cms/Model/Initialize" as const; + + getHandlerAbstraction() { + return ModelInitializeHandler; + } +} + +export const ModelInitializeHandler = createAbstraction>( + "ModelInitializeHandler" +); + +export namespace ModelInitializeHandler { + export type Interface = IEventHandler; + export type Event = ModelInitializeEvent; +} diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/feature.ts new file mode 100644 index 00000000000..e40e6df4d04 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/feature.ts @@ -0,0 +1,17 @@ +import { createFeature } from "@webiny/feature/api"; +import { InitializeModelUseCase } from "./InitializeModelUseCase.js"; + +/** + * InitializeModel Feature + * + * Provides functionality for initializing models with data. + * This is primarily an event dispatch mechanism that allows plugins to + * perform initialization tasks (e.g., creating default entries). + */ +export const InitializeModelFeature = createFeature({ + name: "InitializeModel", + register(container) { + // Register core use case + container.register(InitializeModelUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/index.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/index.ts new file mode 100644 index 00000000000..6edeb620434 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/index.ts @@ -0,0 +1 @@ +export { InitializeModelUseCase } from "./abstractions.js"; From b4ff5b7e0ae4fec2d46223d0fb0e9fd523c89c61 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 12:59:09 +0100 Subject: [PATCH 28/71] wip: migrate content model crud --- .../src/crud/contentModel/beforeDelete.ts | 95 --------------- .../src/crud/contentModel/beforeUpdate.ts | 52 -------- .../contentModelManagerFactory.ts | 23 ---- .../src/domain/contentEntry/errors.ts | 10 +- .../contentEntry/ContentEntriesFeature.ts | 4 + .../CreateEntry/CreateEntryUseCase.ts | 6 +- .../contentEntry/CreateEntry/abstractions.ts | 4 +- .../CreateEntryRevisionFromUseCase.ts | 6 +- .../CreateEntryRevisionFrom/abstractions.ts | 4 +- .../DeleteEntry/DeleteEntryUseCase.ts | 6 +- .../DeleteEntry/MoveEntryToBinUseCase.ts | 6 +- .../contentEntry/DeleteEntry/abstractions.ts | 4 +- .../DeleteEntryRevisionUseCase.ts | 6 +- .../DeleteEntryRevision/abstractions.ts | 4 +- .../DeleteMultipleEntriesUseCase.ts | 4 +- .../DeleteMultipleEntries/abstractions.ts | 4 +- .../GetEntriesByIds/GetEntriesByIdsUseCase.ts | 4 +- .../GetEntriesByIds/abstractions.ts | 4 +- .../contentEntry/GetEntry/abstractions.ts | 4 +- .../contentEntry/GetEntryById/abstractions.ts | 4 +- .../GetLatestEntriesByIdsUseCase.ts | 4 +- .../GetLatestEntriesByIds/abstractions.ts | 4 +- .../GetLatestRevisionByEntryId/BaseUseCase.ts | 4 +- .../abstractions.ts | 4 +- .../BaseUseCase.ts | 4 +- .../abstractions.ts | 4 +- .../GetPublishedEntriesByIdsUseCase.ts | 4 +- .../GetPublishedEntriesByIds/abstractions.ts | 4 +- .../GetRevisionsByEntryIdUseCase.ts | 4 +- .../GetRevisionsByEntryId/abstractions.ts | 4 +- .../GetSingletonEntryUseCase.ts | 58 +++++++++ .../GetSingletonEntry/abstractions.ts | 37 ++++++ .../contentEntry/GetSingletonEntry/feature.ts | 16 +++ .../contentEntry/GetSingletonEntry/index.ts | 1 + .../GetUniqueFieldValuesUseCase.ts | 4 +- .../GetUniqueFieldValues/abstractions.ts | 4 +- .../ListEntries/ListEntriesUseCase.ts | 4 +- .../contentEntry/ListEntries/abstractions.ts | 4 +- .../MoveEntry/MoveEntryUseCase.ts | 6 +- .../contentEntry/MoveEntry/abstractions.ts | 4 +- .../PublishEntry/PublishEntryUseCase.ts | 6 +- .../contentEntry/PublishEntry/abstractions.ts | 4 +- .../RepublishEntry/RepublishEntryUseCase.ts | 6 +- .../RepublishEntry/abstractions.ts | 4 +- .../RestoreEntryFromBinUseCase.ts | 6 +- .../RestoreEntryFromBin/abstractions.ts | 4 +- .../UnpublishEntry/UnpublishEntryUseCase.ts | 6 +- .../UnpublishEntry/abstractions.ts | 4 +- .../UpdateEntry/UpdateEntryUseCase.ts | 6 +- .../contentEntry/UpdateEntry/abstractions.ts | 4 +- .../UpdateSingletonEntryUseCase.ts | 58 +++++++++ .../UpdateSingletonEntry/abstractions.ts | 43 +++++++ .../UpdateSingletonEntry/feature.ts | 16 +++ .../UpdateSingletonEntry/index.ts | 1 + .../ValidateEntry/ValidateEntryUseCase.ts | 6 +- .../ValidateEntry/abstractions.ts | 4 +- .../DeleteModel/DeleteModelRepository.ts | 26 +++- .../DeleteModelWithEntryCleanup.ts | 113 ++++++++++++++++++ .../contentModel/DeleteModel/abstractions.ts | 5 +- .../contentModel/DeleteModel/feature.ts | 5 + .../src/graphql/schema/contentEntries.ts | 10 +- .../schema/resolvers/singular/resolveGet.ts | 14 +-- .../resolvers/singular/resolveUpdate.ts | 13 +- .../api-headless-cms/src/graphqlFields/ref.ts | 33 +++-- .../StorageOperationsCmsModelPlugin.ts | 2 +- .../api-headless-cms/src/validators/unique.ts | 9 +- 66 files changed, 535 insertions(+), 305 deletions(-) delete mode 100644 packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts delete mode 100644 packages/api-headless-cms/src/crud/contentModel/beforeUpdate.ts delete mode 100644 packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/GetSingletonEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/UpdateSingletonEntryUseCase.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/index.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts diff --git a/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts b/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts deleted file mode 100644 index cadfe3f8f21..00000000000 --- a/packages/api-headless-cms/src/crud/contentModel/beforeDelete.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { CmsContext, OnModelBeforeDeleteTopicParams } from "~/types/index.js"; -import WebinyError from "@webiny/error"; -import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; -import { CMS_MODEL_SINGLETON_TAG } from "~/constants.js"; - -interface AssignBeforeModelDeleteParams { - onModelBeforeDelete: Topic; - context: CmsContext; -} -export const assignModelBeforeDelete = (params: AssignBeforeModelDeleteParams) => { - const { onModelBeforeDelete, context } = params; - - onModelBeforeDelete.subscribe(async params => { - const { model } = params; - - const modelPlugin = context.plugins - .byType(CmsModelPlugin.type) - .find(item => item.contentModel.modelId === model.modelId); - - if (modelPlugin) { - throw new WebinyError( - "Content models defined via plugins cannot be deleted.", - "CONTENT_MODEL_DELETE_ERROR", - { - modelId: model.modelId - } - ); - } - - const tags = Array.isArray(model.tags) ? model.tags : []; - /** - * If the model is a singleton, we need to delete all entries. - * There will be either 0 or 1 entries in latest or deleted, but let's put high limit, just in case... - */ - if (tags.includes(CMS_MODEL_SINGLETON_TAG)) { - const [latestEntries] = await context.cms.listLatestEntries(model, { - limit: 10000 - }); - - if (latestEntries.length > 0) { - for (const item of latestEntries) { - await context.cms.deleteEntry(model, item.id, { - permanently: true - }); - } - return; - } - - const [deletedEntries] = await context.cms.listDeletedEntries(model, { - limit: 10000 - }); - - if (deletedEntries.length === 0) { - return; - } - - for (const item of deletedEntries) { - await context.cms.deleteEntry(model, item.id, { - permanently: true - }); - } - - return; - } - - try { - const [latestEntries] = await context.cms.listLatestEntries(model, { limit: 1 }); - - if (latestEntries.length > 0) { - throw new WebinyError( - `Cannot delete content model "${model.modelId}" because there are existing entries.`, - "CONTENT_MODEL_BEFORE_DELETE_HOOK_FAILED" - ); - } - - const [deletedEntries] = await context.cms.listDeletedEntries(model, { limit: 1 }); - - if (deletedEntries.length > 0) { - throw new WebinyError( - `Cannot delete content model "${model.modelId}" because there are existing entries in the trash.`, - "CONTENT_MODEL_BEFORE_DELETE_HOOK_FAILED" - ); - } - } catch (ex) { - throw WebinyError.from(ex, { - message: "Could not retrieve a list of content entries from the model.", - code: "ENTRIES_ERROR", - data: { - model - } - }); - } - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentModel/beforeUpdate.ts b/packages/api-headless-cms/src/crud/contentModel/beforeUpdate.ts deleted file mode 100644 index 005f78f7b05..00000000000 --- a/packages/api-headless-cms/src/crud/contentModel/beforeUpdate.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { CmsContext, OnModelBeforeUpdateTopicParams } from "~/types/index.js"; -import { validateModel } from "./validateModel.js"; -import { validateSingularApiName } from "./validate/singularApiName.js"; -import { validatePluralApiName } from "./validate/pluralApiName.js"; -import { validateEndingAllowed } from "~/crud/contentModel/validate/endingAllowed.js"; - -interface AssignBeforeModelUpdateParams { - onModelBeforeUpdate: Topic; - context: CmsContext; -} - -export const assignModelBeforeUpdate = (params: AssignBeforeModelUpdateParams) => { - const { onModelBeforeUpdate, context } = params; - - onModelBeforeUpdate.subscribe(async ({ model: newModel, original }) => { - const models = await context.security.withoutAuthorization(async () => { - return (await context.cms.listModels()).filter(model => { - return model.modelId !== newModel.modelId; - }); - }); - - validateEndingAllowed({ - model: newModel - }); - /** - * We need to check for the existence of: - * - modelId - * - singularApiName - * - pluralApiName - */ - for (const model of models) { - validateSingularApiName({ - existingModel: model, - model: newModel - }); - validatePluralApiName({ - existingModel: model, - model: newModel - }); - } - /** - * then the model and fields... - */ - await validateModel({ - models, - model: newModel, - original, - context - }); - }); -}; diff --git a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts b/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts deleted file mode 100644 index f7818d87452..00000000000 --- a/packages/api-headless-cms/src/crud/contentModel/contentModelManagerFactory.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CmsModel, CmsContext, ModelManagerPlugin, CmsModelManager } from "~/types/index.js"; - -const defaultName = "content-model-manager-default"; - -export const contentModelManagerFactory = async ( - context: CmsContext, - model: CmsModel -): Promise> => { - const pluginsByType = context.plugins - .byType("cms-content-model-manager") - .reverse(); - for (const plugin of pluginsByType) { - const target = Array.isArray(plugin.modelId) ? plugin.modelId : [plugin.modelId]; - if (target.includes(model.modelId) === true && plugin.name !== defaultName) { - return await plugin.create(context, model); - } - } - const plugin = pluginsByType.find(plugin => plugin.name === defaultName); - if (!plugin) { - throw new Error("There is no default plugin to create CmsModelManager"); - } - return await plugin.create(context, model); -}; diff --git a/packages/api-headless-cms/src/domain/contentEntry/errors.ts b/packages/api-headless-cms/src/domain/contentEntry/errors.ts index e4331dc9686..a6a5bed7131 100644 --- a/packages/api-headless-cms/src/domain/contentEntry/errors.ts +++ b/packages/api-headless-cms/src/domain/contentEntry/errors.ts @@ -41,7 +41,7 @@ export class EntryLockedError extends BaseError { } } -export class ContentEntryNotAuthorizedError extends BaseError { +export class EntryNotAuthorizedError extends BaseError { override readonly code = "Cms/Entry/NotAuthorized" as const; constructor(message?: string) { @@ -50,11 +50,11 @@ export class ContentEntryNotAuthorizedError extends BaseError { }); } - static fromModel(model: CmsModel): ContentEntryNotAuthorizedError { - return new ContentEntryNotAuthorizedError(`Not allowed to access "${model.modelId}" entries.`); + static fromModel(model: CmsModel): EntryNotAuthorizedError { + return new EntryNotAuthorizedError(`Not allowed to access "${model.modelId}" entries.`); } - static fromEntry(entry: CmsEntry): ContentEntryNotAuthorizedError { - return new ContentEntryNotAuthorizedError(`Not allowed to access entry "${entry.entryId}".`); + static fromEntry(entry: CmsEntry): EntryNotAuthorizedError { + return new EntryNotAuthorizedError(`Not allowed to access entry "${entry.entryId}".`); } } diff --git a/packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts b/packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts index 9c95d527725..eb1f3bdd208 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ContentEntriesFeature.ts @@ -23,6 +23,8 @@ import { GetLatestRevisionByEntryIdFeature } from "./GetLatestRevisionByEntryId/ import { GetPublishedRevisionByEntryIdFeature } from "./GetPublishedRevisionByEntryId/feature.js"; import { UnpublishEntryFeature } from "./UnpublishEntry/feature.js"; import { GetUniqueFieldValuesFeature } from "./GetUniqueFieldValues/feature.js"; +import { GetSingletonEntryFeature } from "./GetSingletonEntry/feature.js"; +import { UpdateSingletonEntryFeature } from "./UpdateSingletonEntry/feature.js"; export const ContentEntriesFeature = createFeature({ name: "ContentEntries", @@ -40,6 +42,7 @@ export const ContentEntriesFeature = createFeature({ GetEntryFeature.register(container); ListEntriesFeature.register(container); GetUniqueFieldValuesFeature.register(container); + GetSingletonEntryFeature.register(container); // Command features CreateEntryFeature.register(container); @@ -54,5 +57,6 @@ export const ContentEntriesFeature = createFeature({ DeleteEntryRevisionFeature.register(container); DeleteMultipleEntriesFeature.register(container); RestoreEntryFromBinFeature.register(container); + UpdateSingletonEntryFeature.register(container); } }); diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts index 2a171c9bc24..4477c2a3005 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts @@ -11,7 +11,7 @@ import type { CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { createEntryData } from "~/crud/contentEntry/entryDataFactories/createEntryData.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; @@ -44,7 +44,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } try { @@ -67,7 +67,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromEntry(entry)); + return Result.fail(EntryNotAuthorizedError.fromEntry(entry)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts index e3f17b00d10..63dfa5f8b6b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; import type { EntryPersistenceError, EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * CreateEntry Use Case @@ -16,7 +16,7 @@ export interface ICreateEntryUseCase { } export interface ICreateEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; validation: EntryValidationError; repository: RepositoryError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts index 23203088273..d1bc780ceb3 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/CreateEntryRevisionFromUseCase.ts @@ -19,7 +19,7 @@ import { EntryRevisionAfterCreateEvent, EntryRevisionCreateErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { parseIdentifier } from "@webiny/utils"; import { createEntryRevisionFromData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -58,7 +58,7 @@ class CreateEntryRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the source entry @@ -102,7 +102,7 @@ class CreateEntryRevisionFromUseCaseImpl implements UseCaseAbstraction.Interface }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } try { diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts index 28cfc9a0814..034f42b81de 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts @@ -11,7 +11,7 @@ import type { EntryValidationError, EntryNotFoundError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * CreateEntryRevisionFrom Use Case - Creates a new revision from an existing entry. @@ -26,7 +26,7 @@ export interface ICreateEntryRevisionFromUseCase { } export interface ICreateEntryRevisionFromUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; storage: EntryPersistenceError; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts index 8fb90e2bd4a..5ff3be57fcc 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts @@ -7,7 +7,7 @@ import { GetLatestRevisionByEntryIdIncludingDeletedUseCase } from "~/features/co import type { CmsDeleteEntryOptions, CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryUseCase - Orchestrates permanent deletion of an entry. @@ -41,7 +41,7 @@ class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID @@ -61,7 +61,7 @@ class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new ContentEntryNotAuthorizedError()); + return Result.fail(new EntryNotAuthorizedError()); } try { diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts index 23a7fac70bf..efbda1ae3a6 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/MoveEntryToBinUseCase.ts @@ -8,7 +8,7 @@ import { GetLatestRevisionByEntryIdUseCase } from "~/features/contentEntry/GetLa import type { CmsModel } from "~/types/index.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent, EntryDeleteErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; @@ -38,7 +38,7 @@ class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the entry to delete by ID @@ -58,7 +58,7 @@ class MoveEntryToBinUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new ContentEntryNotAuthorizedError()); + return Result.fail(new EntryNotAuthorizedError()); } // Create the deleted entry data diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts index 1145a1a0b47..26c98f01dbf 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel, CmsDeleteEntryOptions } from "~/types/index.js"; import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntry Use Case - Permanently deletes an entry from the database. @@ -17,7 +17,7 @@ export interface IDeleteEntryUseCase { } export interface IDeleteEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index 10d2fc1b4ef..3b1e43f9633 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -15,7 +15,7 @@ import { EntryRevisionDeleteErrorEvent } from "./events.js"; import { parseIdentifier } from "@webiny/utils"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryRevisionUseCase - Orchestrates deletion of a specific entry revision. @@ -48,7 +48,7 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } const { id: entryId, version } = parseIdentifier(revisionId); @@ -69,7 +69,7 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(new ContentEntryNotAuthorizedError()); + return Result.fail(new EntryNotAuthorizedError()); } // Get the latest revision diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts index 84453660691..db0405c6644 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteEntryRevision Use Case - Deletes a specific revision of an entry. @@ -13,7 +13,7 @@ export interface IDeleteEntryRevisionUseCase { } export interface IDeleteEntryRevisionUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts index 9da559d7da5..50a60e1416a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/DeleteMultipleEntriesUseCase.ts @@ -11,7 +11,7 @@ import { EntryAfterDeleteMultipleEvent, EntryDeleteMultipleErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { parseIdentifier } from "@webiny/utils"; import WebinyError from "@webiny/error"; import { filterAsync } from "~/utils/filterAsync.js"; @@ -67,7 +67,7 @@ class DeleteMultipleEntriesUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "d" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Fetch entries using ListEntries use case diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts index 9878bc1151b..468d1aeb69e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteMultipleEntries/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * DeleteMultipleEntries Use Case - Deletes multiple entries at once. @@ -15,7 +15,7 @@ export interface IDeleteMultipleEntriesUseCase { } export interface IDeleteMultipleEntriesUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts index 330bd0338c2..3a88e8ff715 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/GetEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractions.js" import { GetEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntriesByIdsUseCase - Orchestrates fetching entries by IDs with access control. @@ -26,7 +26,7 @@ class GetEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts index 9e012584081..9f9356e65a8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntriesByIds Use Case - Fetches multiple entries by their exact revision IDs. @@ -16,7 +16,7 @@ export interface IGetEntriesByIdsUseCase { } export interface IGetEntriesByIdsUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts index 4527e0c90b2..0cc1e56af45 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryGetParams, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntry Use Case - Gets a single entry by query parameters (where + sort). @@ -16,7 +16,7 @@ export interface IGetEntryUseCase { } export interface IGetEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts index 8729070367b..933805449db 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetEntryById Use Case - Fetches a single entry by its exact revision ID. @@ -16,7 +16,7 @@ export interface IGetEntryByIdUseCase { } export interface IGetEntryByIdUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts index 360216d358f..eb37eaa390c 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/GetLatestEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetLatestEntriesByIdsUseCase as UseCaseAbstraction } from "./abstractio import { GetLatestEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetLatestEntriesByIdsUseCase - Orchestrates fetching latest entries by IDs. @@ -26,7 +26,7 @@ class GetLatestEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts index 669d1aa2283..f9c40232dc4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetLatestEntriesByIds Use Case - Fetches latest revisions by entry IDs. @@ -16,7 +16,7 @@ export interface IGetLatestEntriesByIdsUseCase { } export interface IGetLatestEntriesByIdsUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts index 528359dc76e..1e0cba05586 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/BaseUseCase.ts @@ -9,7 +9,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Orchestrates fetching latest revision by entry ID. @@ -32,7 +32,7 @@ class GetLatestRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction.In // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts index c53fa4940db..3d438fac3b4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base internal use case - returns entry regardless of deleted state. @@ -21,7 +21,7 @@ export interface IGetLatestRevisionByEntryIdBaseUseCase { } export interface IGetLatestRevisionByEntryIdUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts index b775981da07..0d0b8bb3e3c 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/BaseUseCase.ts @@ -9,7 +9,7 @@ import type { CmsModel, CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Orchestrates fetching previous revision by entry ID and version. @@ -32,7 +32,7 @@ class GetPreviousRevisionByEntryIdUseCaseImpl implements BaseUseCaseAbstraction. // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts index 4498904d424..eb342c8b9b9 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPreviousRevisionByEntryId/abstractions.ts @@ -7,7 +7,7 @@ import type { CmsEntryStorageOperationsGetPreviousRevisionParams } from "~/types/index.js"; import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base internal use case - returns entry regardless of deleted state. @@ -21,7 +21,7 @@ export interface IGetPreviousRevisionByEntryIdBaseUseCase { } export interface IGetPreviousRevisionByEntryIdUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts index 7a53842f4a3..082834bf8ed 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/GetPublishedEntriesByIdsUseCase.ts @@ -4,7 +4,7 @@ import { GetPublishedEntriesByIdsUseCase as UseCaseAbstraction } from "./abstrac import { GetPublishedEntriesByIdsRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedEntriesByIdsUseCase - Orchestrates fetching published entries by IDs. @@ -26,7 +26,7 @@ class GetPublishedEntriesByIdsUseCaseImpl implements UseCaseAbstraction.Interfac // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts index d23d8747b23..a9ff70f2b52 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetPublishedEntriesByIds Use Case - Fetches published revisions by entry IDs. @@ -16,7 +16,7 @@ export interface IGetPublishedEntriesByIdsUseCase { } export interface IGetPublishedEntriesByIdsUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts index 3135b9e4efc..6b6c36cd37b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/GetRevisionsByEntryIdUseCase.ts @@ -4,7 +4,7 @@ import { GetRevisionsByEntryIdUseCase as UseCaseAbstraction } from "./abstractio import { GetRevisionsByEntryIdRepository } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionsByEntryIdUseCase - Orchestrates fetching all revisions for an entry. @@ -26,7 +26,7 @@ class GetRevisionsByEntryIdUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Delegate to repository diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts index 305afa71ca5..2fac1f8df31 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * GetRevisionsByEntryId Use Case - Fetches all revisions for a given entry ID. @@ -16,7 +16,7 @@ export interface IGetRevisionsByEntryIdUseCase { } export interface IGetRevisionsByEntryIdUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/GetSingletonEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/GetSingletonEntryUseCase.ts new file mode 100644 index 00000000000..fa84dc7f351 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/GetSingletonEntryUseCase.ts @@ -0,0 +1,58 @@ +import { Result } from "@webiny/feature/api"; +import { GetSingletonEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants.js"; +import { EntryValidationError } from "~/domain/contentEntry/errors.js"; +import { createCacheKey } from "@webiny/utils"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { CreateEntryUseCase } from "~/features/contentEntry/CreateEntry/index.js"; +import { GetEntryByIdUseCase } from "~/features/contentEntry/GetEntryById/index.js"; + +/** + * GetSingletonEntryUseCase - Gets the singleton entry for a model. + * + * Responsibilities: + * - Validate model is marked as singleton + * - Generate singleton entry ID from model ID + * - Try to get existing entry + * - If not found, create a new entry with skipValidators: ["required"] + * - Delegate to generic GetEntry and CreateEntry use cases + */ +class GetSingletonEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private createEntry: CreateEntryUseCase.Interface + ) {} + + async execute(model: CmsModel): Promise> { + // Validate model is marked as singleton + if (!model.tags?.includes(CMS_MODEL_SINGLETON_TAG)) { + return Result.fail(new EntryValidationError("Model is not marked as singleton.")); + } + + // Generate singleton entry ID from model ID + const id = createCacheKey(model.modelId); + const entryId = `${id}#0001`; + + // Try to get existing entry + const getResult = await this.getEntryById.execute(model, entryId); + + if (getResult.isOk()) { + return getResult; + } + + // Entry doesn't exist, create it + const createResult = await this.createEntry.execute( + model, + { id }, + { skipValidators: ["required"] } + ); + + return createResult; + } +} + +export const GetSingletonEntryUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetSingletonEntryUseCaseImpl, + dependencies: [GetEntryByIdUseCase, CreateEntryUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/abstractions.ts new file mode 100644 index 00000000000..25f8f86df1d --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/abstractions.ts @@ -0,0 +1,37 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import { + type EntryNotFoundError, + type EntryNotAuthorizedError, + type EntryValidationError, + type EntryPersistenceError +} from "~/domain/contentEntry/errors.js"; + +/** + * GetSingletonEntry Use Case + * + * Gets the singleton entry for a model, creating it if it doesn't exist. + */ +export interface IGetSingletonEntryUseCase { + execute(model: CmsModel): Promise>; +} + +export interface IGetSingletonEntryUseCaseErrors { + notFound: EntryNotFoundError; + notAuthorized: EntryNotAuthorizedError; + validation: EntryValidationError; + persistence: EntryPersistenceError; +} + +type UseCaseError = IGetSingletonEntryUseCaseErrors[keyof IGetSingletonEntryUseCaseErrors]; + +export const GetSingletonEntryUseCase = createAbstraction( + "GetSingletonEntryUseCase" +); + +export namespace GetSingletonEntryUseCase { + export type Interface = IGetSingletonEntryUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/feature.ts new file mode 100644 index 00000000000..dfdc83ae5d0 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/feature.ts @@ -0,0 +1,16 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetSingletonEntryUseCase } from "./GetSingletonEntryUseCase.js"; + +/** + * GetSingletonEntry Feature + * + * Provides functionality for getting singleton entries. + * Creates the entry if it doesn't exist. + */ +export const GetSingletonEntryFeature = createFeature({ + name: "GetSingletonEntry", + register(container) { + // Register use case + container.register(GetSingletonEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/index.ts new file mode 100644 index 00000000000..1e3358df14e --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/GetSingletonEntry/index.ts @@ -0,0 +1 @@ +export { GetSingletonEntryUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts index 0c43daf4efe..cac8c3527ea 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/GetUniqueFieldValuesUseCase.ts @@ -6,7 +6,7 @@ import { GetUniqueFieldValuesParams } from "./abstractions.js"; import { AccessControl, CmsContext } from "~/features/shared/abstractions.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; import { getSearchableFields } from "~/crud/contentEntry/searchableFields.js"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; @@ -26,7 +26,7 @@ class GetUniqueFieldValuesUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.accessControl.ensureCanAccessEntry({ model }); } catch (error) { - if (error instanceof ContentEntryNotAuthorizedError) { + if (error instanceof EntryNotAuthorizedError) { return Result.fail(error); } throw error; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts index 2b7588a0634..dca918f17a1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsEntryUniqueValue } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { FieldNotSearchableError, InvalidWhereConditionError } from "./errors.js"; export interface GetUniqueFieldValuesParams { @@ -27,7 +27,7 @@ export interface IGetUniqueFieldValuesUseCase { } export interface IGetUniqueFieldValuesUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; fieldNotSearchable: FieldNotSearchableError; invalidWhere: InvalidWhereConditionError; diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts index 384923e0629..dfac34066a1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListEntriesUseCase.ts @@ -11,7 +11,7 @@ import type { CmsEntryValues, CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * ListEntriesUseCase - Base use case for orchestrating entry listing with access control. @@ -35,7 +35,7 @@ class ListEntriesUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } const { where: initialWhere, ...rest } = params || {}; diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts index 9ec4d0ab776..5e805d84fa8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts @@ -8,7 +8,7 @@ import type { CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * Base ListEntries Use Case - Internal base use case for listing entries. @@ -22,7 +22,7 @@ export interface IListEntriesUseCase { } export interface IListEntriesUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts index 45eba3ffd63..13da7d6fd51 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/MoveEntryUseCase.ts @@ -7,7 +7,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import { EntryBeforeMoveEvent, EntryAfterMoveEvent, EntryMoveErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -36,7 +36,7 @@ class MoveEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the entry to move @@ -56,7 +56,7 @@ class MoveEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Early return if entry is already in the requested folder diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts index 122254b7ae4..b5e9858cc9b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -13,7 +13,7 @@ export interface IMoveEntryUseCase { } export interface IMoveEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts index 58ef870dbec..9c5149000b7 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/PublishEntryUseCase.ts @@ -13,7 +13,7 @@ import { EntryAfterPublishEvent, EntryPublishErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createPublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -48,7 +48,7 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control (publish permission) const canAccess = await this.accessControl.canAccessEntry({ model, pw: "p" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the entry to publish @@ -68,7 +68,7 @@ class PublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the latest revision for entry-level metadata diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index c547b05d5cd..b5d0d9a6942 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError, EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -13,7 +13,7 @@ export interface IPublishEntryUseCase { } export interface IPublishEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; storage: EntryPersistenceError; diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts index ed301483915..ce9cc6d2722 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/RepublishEntryUseCase.ts @@ -12,7 +12,7 @@ import { EntryAfterRepublishEvent, EntryRepublishErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { createRepublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -44,7 +44,7 @@ class RepublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control (write and publish) const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w", pw: "p" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the entry to republish @@ -65,7 +65,7 @@ class RepublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Prepare entry data for republishing diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts index 019c1100ea3..c2a7927b613 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; /** @@ -14,7 +14,7 @@ export interface IRepublishEntryUseCase { } export interface IRepublishEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts index e3b5c28adfa..cb26b324300 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/RestoreEntryFromBinUseCase.ts @@ -12,7 +12,7 @@ import { EntryAfterRestoreFromBinEvent, EntryRestoreFromBinErrorEvent } from "./events.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { getDate } from "~/utils/date.js"; import { getIdentity } from "~/utils/identity.js"; @@ -45,7 +45,7 @@ class RestoreEntryFromBinUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Get the deleted entry to restore by ID @@ -65,7 +65,7 @@ class RestoreEntryFromBinUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Create the restored entry data diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts index 386b3e3aa80..5f7ffb0e102 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry, CmsModel } from "~/types/index.js"; import type { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * RestoreEntryFromBin Use Case - Restores a soft-deleted entry from the bin. @@ -13,7 +13,7 @@ export interface IRestoreEntryFromBinUseCase { } export interface IRestoreEntryFromBinUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; storage: EntryPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts index 105b1f1eda6..8a9bffcf868 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/UnpublishEntryUseCase.ts @@ -12,7 +12,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import { GetPublishedRevisionByEntryIdUseCase } from "~/features/contentEntry/GetPublishedRevisionByEntryId/index.js"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { EntryValidationError } from "~/domain/contentEntry/errors.js"; import { createUnpublishEntryData } from "~/crud/contentEntry/entryDataFactories/index.js"; @@ -44,7 +44,7 @@ class UnpublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, pw: "u" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Parse entry ID from revision ID @@ -76,7 +76,7 @@ class UnpublishEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Transform to unpublish data diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts index 7cf7a84afb8..3effabcb676 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts @@ -5,7 +5,7 @@ import type { CmsModel } from "~/types/index.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import type { EntryPersistenceError } from "~/domain/contentEntry/errors.js"; import type { EntryValidationError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * UnpublishEntry Use Case @@ -15,7 +15,7 @@ export interface IUnpublishEntryUseCase { } export interface IUnpublishEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; validation: EntryValidationError; repository: RepositoryError; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts index e3036bb2fe2..a0b95ea677f 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/UpdateEntryUseCase.ts @@ -16,7 +16,7 @@ import type { UpdateCmsEntryOptionsInput } from "~/types/index.js"; import type { GenericRecord } from "@webiny/api/types.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { EntryLockedError } from "~/domain/contentEntry/errors.js"; import { createUpdateEntryData } from "~/crud/contentEntry/entryDataFactories/createUpdateEntryData.js"; @@ -52,7 +52,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check initial access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } try { @@ -89,7 +89,7 @@ class UpdateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Publish before event diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts index 3786e2359fd..fececaf03df 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts @@ -13,7 +13,7 @@ import type { EntryValidationError, EntryLockedError } from "~/domain/contentEntry/errors.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; /** * UpdateEntry Use Case @@ -29,7 +29,7 @@ export interface IUpdateEntryUseCase { } export interface IUpdateEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; locked: EntryLockedError; validation: EntryValidationError; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/UpdateSingletonEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/UpdateSingletonEntryUseCase.ts new file mode 100644 index 00000000000..2269921fa5c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/UpdateSingletonEntryUseCase.ts @@ -0,0 +1,58 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateSingletonEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetSingletonEntryUseCase } from "~/features/contentEntry/GetSingletonEntry/index.js"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import type { UpdateCmsEntryInput } from "~/types/index.js"; +import type { UpdateCmsEntryOptionsInput } from "~/types/index.js"; +import { UpdateEntryUseCase } from "../UpdateEntry/abstractions.js"; + +// This will be the generic entry use case - using 'any' for now as placeholder +// You'll need to import the actual abstraction when it's created +interface UpdateEntryUseCase { + execute( + model: CmsModel, + entryId: string, + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ): Promise>; +} + +/** + * UpdateSingletonEntryUseCase - Updates the singleton entry for a model. + * + * Responsibilities: + * - Get the singleton entry (creating it if it doesn't exist) + * - Delegate to generic UpdateEntry use case with the entry ID + */ +class UpdateSingletonEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getSingletonEntryUseCase: GetSingletonEntryUseCase.Interface, + private updateEntryUseCase: UpdateEntryUseCase + ) {} + + async execute( + model: CmsModel, + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ): Promise> { + // Get the singleton entry (create a new one if it doesn't exist) + const getResult = await this.getSingletonEntryUseCase.execute(model); + + if (getResult.isFail()) { + return getResult; + } + + const entry = getResult.value; + + // Update the entry using the regular update use case + const updateResult = await this.updateEntryUseCase.execute(model, entry.id, data, options); + + return updateResult; + } +} + +export const UpdateSingletonEntryUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateSingletonEntryUseCaseImpl, + dependencies: [GetSingletonEntryUseCase, UpdateEntryUseCase] +}); diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/abstractions.ts new file mode 100644 index 00000000000..5ba4f01e97c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/abstractions.ts @@ -0,0 +1,43 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { CmsEntry } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; +import type { UpdateCmsEntryInput } from "~/types/index.js"; +import type { UpdateCmsEntryOptionsInput } from "~/types/index.js"; +import { + type EntryNotFoundError, + type EntryNotAuthorizedError, + type EntryValidationError, + type EntryPersistenceError +} from "~/domain/contentEntry/errors.js"; + +/** + * UpdateSingletonEntry Use Case + * + * Updates the singleton entry for a model. + */ +export interface IUpdateSingletonEntryUseCase { + execute( + model: CmsModel, + data: UpdateCmsEntryInput, + options?: UpdateCmsEntryOptionsInput + ): Promise>; +} + +export interface IUpdateSingletonEntryUseCaseErrors { + notFound: EntryNotFoundError; + notAuthorized: EntryNotAuthorizedError; + validation: EntryValidationError; + persistence: EntryPersistenceError; +} + +type UseCaseError = IUpdateSingletonEntryUseCaseErrors[keyof IUpdateSingletonEntryUseCaseErrors]; + +export const UpdateSingletonEntryUseCase = createAbstraction( + "UpdateSingletonEntryUseCase" +); + +export namespace UpdateSingletonEntryUseCase { + export type Interface = IUpdateSingletonEntryUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/feature.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/feature.ts new file mode 100644 index 00000000000..e24e0ceb323 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/feature.ts @@ -0,0 +1,16 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateSingletonEntryUseCase } from "./UpdateSingletonEntryUseCase.js"; + +/** + * UpdateSingletonEntry Feature + * + * Provides functionality for updating singleton entries. + * Gets the entry (creating if needed) then updates it. + */ +export const UpdateSingletonEntryFeature = createFeature({ + name: "UpdateSingletonEntry", + register(container) { + // Register use case + container.register(UpdateSingletonEntryUseCase); + } +}); diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/index.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/index.ts new file mode 100644 index 00000000000..ce1d6c2aa3c --- /dev/null +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateSingletonEntry/index.ts @@ -0,0 +1 @@ +export { UpdateSingletonEntryUseCase } from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts index 7b5ca839aff..a4036c7c2db 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/ValidateEntryUseCase.ts @@ -4,7 +4,7 @@ import { ValidateEntryUseCase as UseCaseAbstraction } from "./abstractions.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; -import { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import { mapAndCleanUpdatedInputData } from "~/crud/contentEntry/entryDataFactories/index.js"; import { validateModelEntryData } from "~/crud/contentEntry/entryDataValidation.js"; import { CmsContext } from "~/features/shared/abstractions.js"; @@ -34,7 +34,7 @@ class ValidateEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check access control const canAccess = await this.accessControl.canAccessEntry({ model, rwd: "w" }); if (!canAccess) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } // Map and clean input data @@ -59,7 +59,7 @@ class ValidateEntryUseCaseImpl implements UseCaseAbstraction.Interface { }); if (!canAccessEntry) { - return Result.fail(ContentEntryNotAuthorizedError.fromModel(model)); + return Result.fail(EntryNotAuthorizedError.fromModel(model)); } } diff --git a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts index bd13249007a..d0220a150a8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ValidateEntry/abstractions.ts @@ -1,7 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsModel, CmsModelFieldValidation } from "~/types/index.js"; -import type { ContentEntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; import type { EntryNotFoundError } from "~/domain/contentEntry/errors.js"; import { GetRevisionByIdUseCase } from "~/features/contentEntry/GetRevisionById/index.js"; @@ -18,7 +18,7 @@ export interface IValidateEntryUseCase { } export interface IValidateEntryUseCaseErrors { - notAuthorized: ContentEntryNotAuthorizedError; + notAuthorized: EntryNotAuthorizedError; notFound: EntryNotFoundError; getRevisionById: GetRevisionByIdUseCase.Error; } diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts index d093e98c3a1..0bb12e09a38 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts @@ -2,26 +2,44 @@ import { Result } from "@webiny/feature/api"; import { DeleteModelRepository as RepositoryAbstraction } from "./abstractions.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; +import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; import type { CmsModel } from "~/types/index.js"; /** - * DeleteModelRepository - Deletes a model from storage. + * DeleteModelRepository - Validates and deletes a model from storage. * * Responsibilities: + * - Validate model is not defined via plugin (core domain rule) * - Delete from storage * - Clear ModelCache after successful deletion * - * Note: Validation (checking for entries, plugin models) should be done in event handlers + * Note: Entry validation and cleanup is handled by decorator */ class DeleteModelRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private modelCache: ModelCache.Interface, - private storageOperations: StorageOperations.Interface + private storageOperations: StorageOperations.Interface, + private cmsContext: CmsContext.Interface ) {} async execute(model: CmsModel): Promise> { try { + // Check if model is defined via plugin (core domain rule) + const modelPlugin = this.cmsContext.plugins + .byType(CmsModelPlugin.type) + .find(item => item.contentModel.modelId === model.modelId); + + if (modelPlugin) { + return Result.fail( + new ModelValidationError( + "Content models defined via plugins cannot be deleted." + ) + ); + } + // Delete from storage await this.storageOperations.models.delete({ model }); @@ -37,5 +55,5 @@ class DeleteModelRepositoryImpl implements RepositoryAbstraction.Interface { export const DeleteModelRepository = RepositoryAbstraction.createImplementation({ implementation: DeleteModelRepositoryImpl, - dependencies: [ModelCache, StorageOperations] + dependencies: [ModelCache, StorageOperations, CmsContext] }); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts new file mode 100644 index 00000000000..2eb843c4bed --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts @@ -0,0 +1,113 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteModelUseCase } from "./abstractions.js"; +import { CmsContext } from "~/features/shared/abstractions.js"; +import { CMS_MODEL_SINGLETON_TAG } from "~/constants.js"; +import { ModelPersistenceError, ModelValidationError } from "~/domain/contentModel/errors.js"; + +/** + * DeleteModelWithEntryCleanup - Decorator that handles entry cleanup/validation before deletion. + * + * Responsibilities: + * 1. For singleton models: Delete all entries (latest + deleted) + * 2. For regular models: Check if there are any entries - if yes, throw error + * 3. Only proceed to the base use case if validations pass + */ +class DeleteModelWithEntryCleanupImpl implements DeleteModelUseCase.Interface { + constructor( + private cmsContext: CmsContext.Interface, + private decoratee: DeleteModelUseCase.Interface + ) {} + + async execute(modelId: string): Promise> { + // First, get the model through the decorated use case's flow (up to before deletion) + // We need to perform validation before the actual deletion happens + + // Get the model from context to check entries + const model = await this.cmsContext.cms.getModel(modelId); + + const tags = Array.isArray(model.tags) ? model.tags : []; + + // Handle singleton models: delete all entries + if (tags.includes(CMS_MODEL_SINGLETON_TAG)) { + try { + await this.deleteSingletonEntries(model); + } catch (error) { + return Result.fail( + new ModelValidationError( + `Failed to delete singleton entries: ${(error as Error).message}` + ) + ); + } + } else { + const hasEntriesResult = await this.checkForExistingEntries(model); + if (hasEntriesResult.isFail()) { + return Result.fail(hasEntriesResult.error); + } + + const hasEntries = hasEntriesResult.value; + if (hasEntries) { + return Result.fail( + new ModelValidationError( + `Cannot delete content model "${model.modelId}" because there are existing entries.` + ) + ); + } + } + + // Proceed with the actual deletion + return this.decoratee.execute(modelId); + } + + private async deleteSingletonEntries(model: any): Promise { + // Delete all latest entries + const [latestEntries] = await this.cmsContext.cms.listLatestEntries(model, { + limit: 10000 + }); + + for (const item of latestEntries) { + await this.cmsContext.cms.deleteEntry(model, item.id, { + permanently: true + }); + } + + // Delete all deleted entries (trash) + const [deletedEntries] = await this.cmsContext.cms.listDeletedEntries(model, { + limit: 10000 + }); + + for (const item of deletedEntries) { + await this.cmsContext.cms.deleteEntry(model, item.id, { + permanently: true + }); + } + } + + private async checkForExistingEntries( + model: any + ): Promise> { + try { + // Check for latest entries + const [latestEntries] = await this.cmsContext.cms.listLatestEntries(model, { + limit: 1 + }); + + if (latestEntries.length > 0) { + return Result.ok(true); + } + + // Check for deleted entries (trash) + const [deletedEntries] = await this.cmsContext.cms.listDeletedEntries(model, { + limit: 1 + }); + + return Result.ok(deletedEntries.length > 0); + } catch (error) { + return Result.fail(new ModelPersistenceError(error)); + } + } +} + +export const DeleteModelWithEntryCleanup = DeleteModelUseCase.createDecorator({ + decorator: DeleteModelWithEntryCleanupImpl, + dependencies: [CmsContext] +}); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts index bfcbfbb96a1..9a74cc36c82 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts @@ -4,6 +4,7 @@ import type { CmsModel } from "~/types/index.js"; import { ModelNotAuthorizedError, type ModelNotFoundError, + type ModelValidationError, type ModelPersistenceError } from "~/domain/contentModel/errors.js"; @@ -18,6 +19,7 @@ export interface IDeleteModelUseCaseErrors { notFound: ModelNotFoundError; notAuthorized: ModelNotAuthorizedError; persistence: ModelPersistenceError; + validation: ModelValidationError; } type UseCaseError = IDeleteModelUseCaseErrors[keyof IDeleteModelUseCaseErrors]; @@ -30,13 +32,14 @@ export namespace DeleteModelUseCase { } /** - * DeleteModelRepository - Deletes a model from storage. + * DeleteModelRepository - Validates and deletes a model from storage. */ export interface IDeleteModelRepository { execute(model: CmsModel): Promise>; } export interface IDeleteModelRepositoryErrors { + validation: ModelValidationError; persistence: ModelPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts index a2576a9e716..ae7b636b3cf 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import { DeleteModelUseCase } from "./DeleteModelUseCase.js"; import { DeleteModelRepository } from "./DeleteModelRepository.js"; +import { DeleteModelWithEntryCleanup } from "./DeleteModelWithEntryCleanup.js"; /** * DeleteModel Feature * * Provides functionality for deleting content models. + * Includes entry cleanup for singleton models and validation for regular models. */ export const DeleteModelFeature = createFeature({ name: "DeleteModel", @@ -15,5 +17,8 @@ export const DeleteModelFeature = createFeature({ // Register repository in singleton scope (shared instance) container.register(DeleteModelRepository).inSingletonScope(); + + // Register entry cleanup decorator + container.registerDecorator(DeleteModelWithEntryCleanup); } }); diff --git a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts index 65d38177e9d..bf921bbd2f2 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentEntries.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentEntries.ts @@ -16,6 +16,7 @@ import { entryFieldFromStorageTransform } from "~/utils/entryStorage.js"; import type { GraphQLFieldResolver } from "@webiny/handler-graphql/types.js"; import { ENTRY_META_FIELDS, isDateTimeEntryMetaField } from "~/constants.js"; import NotAuthorizedResponse from "@webiny/api-core/graphql/security/NotAuthorizedResponse.js"; +import { ListLatestEntriesUseCase } from "~/features/contentEntry/ListEntries/index.js"; interface EntriesByModel { [key: string]: string[]; @@ -440,17 +441,20 @@ export const createContentEntriesSchema = ({ const getters = models .filter(model => modelIds.includes(model.modelId)) .map(async model => { - const modelManager = await context.cms.getEntryManager(model.modelId); + const listLatest = + await context.container.resolve(ListLatestEntriesUseCase); const where: CmsEntryListWhere = {}; - const [items] = await modelManager.listLatest({ + const result = await listLatest.execute(model, { limit, where, search: !!query ? query : undefined, fields: fields || [] }); - return items.map((entry: CmsEntry) => { + const [entries] = result.value; + + return entries.map((entry: CmsEntry) => { return createCmsEntryRecord(model, entry); }); }); diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts index fcc7e9f2193..eb8268371cf 100644 --- a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveGet.ts @@ -1,5 +1,6 @@ import { ErrorResponse, Response } from "@webiny/handler-graphql/responses.js"; import type { CmsEntryResolverFactory as ResolverFactory } from "~/types/index.js"; +import { GetSingletonEntryUseCase } from "~/features/contentEntry/GetSingletonEntry/index.js"; interface ResolveGetArgs { revision: string; @@ -10,12 +11,11 @@ type ResolveGet = ResolverFactory; export const resolveGet: ResolveGet = ({ model }) => async (_: unknown, __: unknown, context) => { - try { - const manager = await context.cms.getSingletonEntryManager(model); - const entry = await manager.get(); - - return new Response(entry); - } catch (e) { - return new ErrorResponse(e); + const getEntry = context.container.resolve(GetSingletonEntryUseCase); + const entry = await getEntry.execute(model); + if (entry.isFail()) { + return new ErrorResponse(entry.error); } + + return new Response(entry.value); }; diff --git a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts index aa592bb782f..4ae64058bf4 100644 --- a/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts +++ b/packages/api-headless-cms/src/graphql/schema/resolvers/singular/resolveUpdate.ts @@ -4,6 +4,7 @@ import type { UpdateCmsEntryInput, UpdateCmsEntryOptionsInput } from "~/types/index.js"; +import { UpdateSingletonEntryUseCase } from "~/features/contentEntry/UpdateSingletonEntry/index.js"; interface ResolveUpdateArgs { data: UpdateCmsEntryInput; @@ -15,12 +16,12 @@ type ResolveUpdate = ResolverFactory; export const resolveUpdate: ResolveUpdate = ({ model }) => async (_: unknown, args, context) => { - try { - const manager = await context.cms.getSingletonEntryManager(model.modelId); - const entry = await manager.update(args.data, args.options); + const updateEntry = await context.container.resolve(UpdateSingletonEntryUseCase); + const entry = await updateEntry.execute(model, args.data, args.options); - return new Response(entry); - } catch (e) { - return new ErrorResponse(e); + if (entry.isFail()) { + return new ErrorResponse(entry.error); } + + return new Response(entry.value); }; diff --git a/packages/api-headless-cms/src/graphqlFields/ref.ts b/packages/api-headless-cms/src/graphqlFields/ref.ts index e44bf0d5abc..f7cce319f78 100644 --- a/packages/api-headless-cms/src/graphqlFields/ref.ts +++ b/packages/api-headless-cms/src/graphqlFields/ref.ts @@ -9,6 +9,9 @@ import type { import { createTypeName } from "~/utils/createTypeName.js"; import { parseIdentifier } from "@webiny/utils"; import { createGraphQLInputField } from "./helpers.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { GetPublishedEntriesByIdsUseCase } from "~/features/contentEntry/GetPublishedEntriesByIds/index.js"; +import { GetLatestEntriesByIdsUseCase } from "~/features/contentEntry/GetLatestEntriesByIds/index.js"; interface RefFieldValue { /** @@ -150,7 +153,11 @@ export const createRefField = (): CmsModelFieldToGraphQLPlugin => { } return async (parent, args, context: CmsContext) => { - const { cms } = context; + const { cms, container } = context; + + const getModel = container.resolve(GetModelUseCase); + const getPublishedByIds = container.resolve(GetPublishedEntriesByIdsUseCase); + const getLatestByIds = container.resolve(GetLatestEntriesByIdsUseCase); // Get field value for this entry const initialValue = parent[field.fieldId] as RefFieldValue | RefFieldValue[]; @@ -192,17 +199,24 @@ export const createRefField = (): CmsModelFieldToGraphQLPlugin => { const getters = Object.keys(entriesByModel).map(async modelId => { const idList = entriesByModel[modelId]; + // Get model manager, to get access to CRUD methods - const model = await cms.getEntryManager(modelId); + const modelResult = await getModel.execute(modelId); + const model = modelResult.value; let entries: CmsEntry[]; // `read` API works with `published` data if (cms.READ) { - entries = await model.getPublishedByIds(idList); + const getPublishedResult = await getPublishedByIds.execute( + model, + idList + ); + entries = getPublishedResult.value; } // `preview` and `manage` with `latest` data else { - entries = await model.getLatestByIds(idList); + const latestByIsResult = await getLatestByIds.execute(model, idList); + entries = latestByIsResult.value; } return appendTypename(entries, modelIdToTypeName.get(modelId)); }); @@ -228,16 +242,21 @@ export const createRefField = (): CmsModelFieldToGraphQLPlugin => { const value = initialValue as RefFieldValue; // Get model manager, to get access to CRUD methods - const model = await cms.getEntryManager(value.modelId); + const modelResult = await getModel.execute(value.modelId); + const model = modelResult.value; let revisions: CmsEntry[]; // `read` API works with `published` data if (cms.READ) { - revisions = await model.getPublishedByIds([value.entryId]); + const publishedByIdsResult = await getPublishedByIds.execute(model, [ + value.entryId + ]); + revisions = publishedByIdsResult.value; } // `preview` API works with `latest` data else { - revisions = await model.getLatestByIds([value.entryId]); + const latestByIdsResult = await getLatestByIds.execute(model, [value.entryId]); + revisions = latestByIdsResult.value; } /** diff --git a/packages/api-headless-cms/src/plugins/StorageOperationsCmsModelPlugin.ts b/packages/api-headless-cms/src/plugins/StorageOperationsCmsModelPlugin.ts index 89563b97744..fd3419cce08 100644 --- a/packages/api-headless-cms/src/plugins/StorageOperationsCmsModelPlugin.ts +++ b/packages/api-headless-cms/src/plugins/StorageOperationsCmsModelPlugin.ts @@ -39,6 +39,6 @@ export class StorageOperationsCmsModelPlugin extends Plugin { * The models created via the CRUD operations might get changed in the middle of the call, so we need to re-create the SO model. */ private createCacheKey(model: CmsModel): string { - return [model.tenant, model.locale, model.modelId, model.savedOn || "unknown"].join("#"); + return [model.tenant, model.modelId, model.savedOn || "unknown"].join("#"); } } diff --git a/packages/api-headless-cms/src/validators/unique.ts b/packages/api-headless-cms/src/validators/unique.ts index 88a45df6b0a..cea3bb4e292 100644 --- a/packages/api-headless-cms/src/validators/unique.ts +++ b/packages/api-headless-cms/src/validators/unique.ts @@ -1,5 +1,6 @@ import WebinyError from "@webiny/error"; import type { CmsModelFieldValidatorPlugin } from "~/types/index.js"; +import { ListLatestEntriesUseCase } from "~/features/contentEntry/ListEntries/index.js"; /** * Validation if the field value is unique. @@ -12,7 +13,6 @@ export const createUniqueValidator = (): CmsModelFieldValidatorPlugin => { validator: { name: "unique", validate: async ({ field, value: initialValue, context, model, entry }) => { - const manager = await context.cms.getEntryManager(model); /** * If there is no value passed, we are assuming that user does not want any value to be validated. * If user needs something to passed into a unique field, they must add "required" validator. @@ -22,14 +22,17 @@ export const createUniqueValidator = (): CmsModelFieldValidatorPlugin => { return true; } try { - const [items] = await manager.listLatest({ + const listLatest = context.container.resolve(ListLatestEntriesUseCase); + + const listResult = await listLatest.execute(model, { where: { entryId_not: entry ? entry.entryId : undefined, [field.fieldId]: value }, limit: 1 }); - return items.length === 0; + + return listResult.value[0].length === 0; } catch (ex) { throw new WebinyError( "Error while checking if the field value is unique.", From 475c63fbd1dade0bfd6a8b6451d6e9e5a3ae64c4 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 16:12:27 +0100 Subject: [PATCH 29/71] wip: migrate content model crud --- .../src/definitions/model.ts | 4 - .../operations/helpers/createModel.ts | 2 - .../src/definitions/model.ts | 4 - .../authorWithSearchableJson/context.ts | 2 +- .../authorWithSearchableJson/manager.ts | 2 +- .../authorWithSearchableJson/reader.ts | 2 +- .../contentAPI/aco/acoGraphQl.test.ts | 4 +- .../__tests__/contentAPI/benchmark.test.ts | 2 +- .../contentAPI/cmsEndpointAccess.test.ts | 6 +- .../contentAPI/cmsEntryStatus.test.ts | 2 +- .../contentEntry.crud.validation.test.ts | 14 +-- .../contentEntry.optionalValidation.test.ts | 26 ++--- .../contentAPI/cmsEntryValidation/handler.ts | 2 +- .../contentAPI/contentEntries.test.ts | 2 +- .../contentAPI/contentEntry.delete.test.ts | 34 +++---- .../contentEntry.deleteMultiple.test.ts | 8 +- .../contentAPI/contentEntry.move.test.ts | 2 +- .../contentAPI/contentEntry.restore.test.ts | 12 +-- .../contentAPI/contentEntry.withId.test.ts | 6 +- .../contentEntryCustomDates.test.ts | 2 +- .../contentEntryCustomIdentities.test.s.ts | 2 +- .../contentAPI/contentEntryHooks.test.ts | 97 +------------------ .../contentAPI/contentEntryMetaField.test.ts | 5 +- .../contentAPI/contentModel.clone.test.ts | 38 +++----- .../contentModel.crud.defaultFields.test.ts | 2 +- .../contentModel.crud.images.test.ts | 2 +- .../contentModel.crud.modelId.test.ts | 6 +- .../contentModel.crud.noFieldPlugin.test.ts | 8 +- .../contentModel.crud.private.test.ts | 2 +- ...contentModel.crud.reservedModelIds.test.ts | 6 +- .../contentAPI/contentModel.crud.test.ts | 32 +++--- .../contentModel.crud.uniqueModelId.test.ts | 34 +++---- .../contentAPI/deepNestedObject.test.ts | 2 +- .../contentAPI/dynamicZoneField.test.ts | 4 +- .../contentAPI/entryPagination.test.ts | 4 +- .../contentAPI/export.structure.test.ts | 2 +- .../contentAPI/extendingGqlSchema.test.ts | 2 +- .../extendingGqlSchemaError.test.ts | 2 +- .../contentAPI/fieldValidations.test.ts | 2 +- .../__tests__/contentAPI/filtering.test.ts | 4 +- .../contentAPI/graphQlAndQueries.test.ts | 4 +- .../contentAPI/graphQlOrQueries.test.ts | 2 +- .../__tests__/contentAPI/httpOptions.test.ts | 2 +- .../contentAPI/import.structure.test.ts | 6 +- .../contentAPI/latestEntries.test.ts | 6 +- .../mocks/contentModels.noValidation.ts | 2 - .../contentAPI/mocks/contentModels.ts | 19 ---- .../mocks/pageWithDynamicZonesModel.ts | 2 - .../__tests__/contentAPI/model.delete.test.ts | 8 +- .../contentAPI/multipleValues.test.ts | 2 +- .../contentAPI/pluginsContentModels.test.ts | 24 +++-- .../pluginsContentModelsRef.test.ts | 8 +- .../contentAPI/predefinedValues.test.ts | 2 +- .../__tests__/contentAPI/refField.test.ts | 4 +- .../__tests__/contentAPI/references.test.ts | 4 +- .../publishedAndUnpublished.test.ts | 4 +- .../contentAPI/republish.entries.test.ts | 8 +- .../resolvers.apiKey.manage.test.ts | 4 +- .../contentAPI/resolvers.apiKey.read.test.ts | 4 +- .../contentAPI/resolvers.manage.test.ts | 4 +- .../contentAPI/resolvers.read.test.ts | 4 +- .../contentAPI/revisionIdScalar.test.ts | 2 +- .../contentAPI/richTextField.test.ts | 4 +- .../__tests__/contentAPI/search.test.ts | 4 +- .../security/contentEntries/write.test.ts | 1 + .../security/contentModels/delete.test.ts | 1 + .../security/contentModels/write.test.ts | 2 + .../__tests__/contentAPI/sorting.test.ts | 4 +- .../contentAPI/storageTransform.test.ts | 2 +- .../contentTraverser/mocks/page.entry.ts | 1 - .../mocks/fieldIdStorageConverter.ts | 2 - .../filtering/product.conditional.test.ts | 2 +- .../filtering/product.nestedObject.test.ts | 2 +- .../__tests__/graphql/numbersModel.test.ts | 2 +- .../__tests__/plugins/storage/object/model.ts | 2 - .../storageOperations/entries.test.ts | 2 +- .../fieldUniqueValues.test.ts | 2 +- .../__tests__/storageOperations/helpers.ts | 4 - packages/api-headless-cms/__tests__/types.ts | 1 - .../__tests__/validations/fields/text.ts | 6 -- .../__tests__/validations/models/test.ts | 1 - .../validations/validateModelFields.test.ts | 28 +----- .../src/crud/AccessControl/AccessControl.ts | 18 ---- .../src/domain/contentModel/errors.ts | 55 +++++++++-- .../ListEntries/ListLatestEntriesUseCase.ts | 1 + .../CreateModel/CreateModelRepository.ts | 39 ++++---- .../CreateModel/CreateModelUseCase.ts | 4 +- .../contentModel/CreateModel/abstractions.ts | 7 +- .../CreateModelFromRepository.ts | 31 +++--- .../CreateModelFrom/abstractions.ts | 6 +- .../DeleteModel/DeleteModelRepository.ts | 33 +++---- .../DeleteModelWithEntryCleanup.ts | 50 ++++++---- .../contentModel/DeleteModel/abstractions.ts | 9 +- .../GetModel/GetModelRepository.ts | 1 - .../ListModels/ListModelsRepository.ts | 1 - .../ListModels/ListModelsUseCase.ts | 12 ++- .../UpdateModel/UpdateModelRepository.ts | 12 ++- .../contentModel/UpdateModel/abstractions.ts | 4 +- .../contentModel/shared/ModelsFetcher.ts | 55 +++-------- .../ListGroups/ListGroupsRepository.ts | 4 +- .../src/graphql/schema/contentModels.ts | 1 - packages/api-headless-cms/src/types/types.ts | 4 - 102 files changed, 373 insertions(+), 566 deletions(-) diff --git a/packages/api-headless-cms-ddb-es/src/definitions/model.ts b/packages/api-headless-cms-ddb-es/src/definitions/model.ts index d99375c7dfc..12a5fe177fc 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/model.ts @@ -83,10 +83,6 @@ export const createModelEntity = (params: CreateModelEntityParams): Entity required: false, default: [] }, - lockedFields: { - type: "list", - required: true - }, titleFieldId: { type: "string" }, diff --git a/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts b/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts index 8af6014db98..43289296221 100644 --- a/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts +++ b/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts @@ -9,9 +9,7 @@ export const createModel = (): CmsModel => { return { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Category", singularApiName: "Category", pluralApiName: "Categories", diff --git a/packages/api-headless-cms-ddb/src/definitions/model.ts b/packages/api-headless-cms-ddb/src/definitions/model.ts index bef1f2db9d3..3a33aba9697 100644 --- a/packages/api-headless-cms-ddb/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb/src/definitions/model.ts @@ -79,10 +79,6 @@ export const createModelEntity = (params: Params): Entity => { required: false, default: [] }, - lockedFields: { - type: "list", - required: true - }, titleFieldId: { type: "string" }, diff --git a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/context.ts b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/context.ts index 7922019daee..e705a7065c5 100644 --- a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/context.ts +++ b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/context.ts @@ -3,7 +3,7 @@ import { createAuthorWithSearchableJson } from "~tests/__helpers/models/authorWi import { createDefaultGroup } from "~tests/__helpers/groups/defaultGroup.js"; export const createAuthorWithSearchableJsonContextHandler = () => { - const path = "manage/en-US"; + const path = "manage"; const result = useHandler({ path, plugins: [createDefaultGroup(), createAuthorWithSearchableJson()] diff --git a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/manager.ts b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/manager.ts index bea65fd9cf6..9d0be644933 100644 --- a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/manager.ts +++ b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/manager.ts @@ -13,7 +13,7 @@ import { createAuthorWithSearchableJson } from "~tests/__helpers/models/authorWi export const useAuthorWithSearchableJsonManager = (params?: GraphQLHandlerParams) => { const modelPlugin = createAuthorWithSearchableJson(); const contentHandler = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [createDefaultGroup(), modelPlugin], ...params }); diff --git a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/reader.ts b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/reader.ts index 54cfa172082..5203b26c2d0 100644 --- a/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/reader.ts +++ b/packages/api-headless-cms/__tests__/__helpers/handler/authorWithSearchableJson/reader.ts @@ -13,7 +13,7 @@ import { createAuthorWithSearchableJson } from "~tests/__helpers/models/authorWi export const useAuthorWithSearchableJsonReader = (params?: GraphQLHandlerParams) => { const modelPlugin = createAuthorWithSearchableJson(); const contentHandler = useGraphQLHandler({ - path: "read/en-US", + path: "read", plugins: [createDefaultGroup(), modelPlugin], ...params }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/aco/acoGraphQl.test.ts b/packages/api-headless-cms/__tests__/contentAPI/aco/acoGraphQl.test.ts index 7ead5b2c7e1..bbdd9585054 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/aco/acoGraphQl.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/aco/acoGraphQl.test.ts @@ -33,7 +33,7 @@ const createExpectedListResponse = (folderId?: string) => { describe("extending the GraphQL", () => { it("should extend the model with a location field and update location via ACO method", async () => { const { getEntry, createEntry, updateEntryLocation } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); const [createResponse] = await createEntry({ @@ -98,7 +98,7 @@ describe("extending the GraphQL", () => { it("should list entries with location", async () => { const { createEntry, updateEntryLocation, listEntries } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); const [createResponse] = await createEntry({ diff --git a/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts b/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts index 490f959de7b..031682db78f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/benchmark.test.ts @@ -6,7 +6,7 @@ describe("benchmark points", () => { let elapsed = 0; const { createContentModelGroupMutation } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", topPlugins: [ new ContextPlugin(async context => { context.benchmark.enable(); diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEndpointAccess.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEndpointAccess.test.ts index c1455e7fd24..f2f4d552d89 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEndpointAccess.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEndpointAccess.test.ts @@ -8,9 +8,9 @@ import models from "./mocks/contentModels"; describe("Endpoint access", () => { let contentModelGroup: CmsGroup; - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; - const previewOpts = { path: "preview/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; + const previewOpts = { path: "preview" }; const defaultPermissions = [ { name: "content.i18n", diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryStatus.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryStatus.test.ts index 053abcb5904..62cfd974908 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryStatus.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryStatus.test.ts @@ -20,7 +20,7 @@ const categories = [ describe("cms entry status filtering", () => { const manageOpts = { - path: "manage/en-US" + path: "manage" }; const { diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts index 94d29461222..2639d7fa8d1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.crud.validation.test.ts @@ -51,7 +51,7 @@ describe("content entry validation", () => { fields: [field] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -81,7 +81,7 @@ describe("content entry validation", () => { fields: [field] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -105,7 +105,7 @@ describe("content entry validation", () => { it("should return errors for invalid entry", async () => { const { plugins, model } = createValidationStructure(); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -145,7 +145,7 @@ describe("content entry validation", () => { fields: [field] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -169,7 +169,7 @@ describe("content entry validation", () => { const { plugins, model } = createValidationStructure(); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -210,7 +210,7 @@ describe("content entry validation", () => { ...(pageModel as CmsModel) }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -326,7 +326,7 @@ describe("content entry validation", () => { it("should not return errors for valid entry", async () => { const { plugins, model } = createValidationStructure(); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts index 1e59c244c63..93a1b661669 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts @@ -15,7 +15,7 @@ describe("content entry picked validation", () => { fields: [createTextField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -79,7 +79,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -146,7 +146,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -215,7 +215,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -283,7 +283,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -351,7 +351,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -407,7 +407,7 @@ describe("content entry picked validation", () => { fields: [createNumberField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -463,7 +463,7 @@ describe("content entry picked validation", () => { fields: [createDateField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -519,7 +519,7 @@ describe("content entry picked validation", () => { fields: [createDateField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -587,7 +587,7 @@ describe("content entry picked validation", () => { ] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -643,7 +643,7 @@ describe("content entry picked validation", () => { fields: [createTimeField()] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -699,7 +699,7 @@ describe("content entry picked validation", () => { fields: [createTextField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); @@ -763,7 +763,7 @@ describe("content entry picked validation", () => { fields: [createTextField({})] }); const manager = useValidationManageHandler({ - path: "manage/en-US", + path: "manage", plugins, model }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/handler.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/handler.ts index 239ad9a4b58..853115d18b3 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/handler.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/handler.ts @@ -15,7 +15,7 @@ interface Params extends Partial { export const useValidationManageHandler = (params: Params) => { const contentHandler = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", ...params }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntries.test.ts index 473f17e2830..8c8a2b7b873 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntries.test.ts @@ -93,7 +93,7 @@ type CmsEntry> = T & { }; describe("Content entries", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const mainManager = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts index d1c6a64ab92..67b8b0451d6 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.delete.test.ts @@ -18,10 +18,10 @@ type Categories = CmsEntry[]; describe("delete entries", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const reader = useCategoryReadHandler({ - path: "read/en-US" + path: "read" }); const createCategory = async (data: CreateCategoryParams) => { @@ -132,7 +132,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -143,7 +143,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -161,7 +161,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -230,7 +230,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -264,7 +264,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -275,7 +275,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -296,7 +296,7 @@ describe("delete entries", () => { createCategoryFrom: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: expect.any(String) } @@ -315,7 +315,7 @@ describe("delete entries", () => { updateCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: expect.any(String) } @@ -331,7 +331,7 @@ describe("delete entries", () => { publishCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: expect.any(String) } @@ -347,7 +347,7 @@ describe("delete entries", () => { unpublishCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: expect.any(String) } @@ -364,7 +364,7 @@ describe("delete entries", () => { moveCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: expect.any(String) } @@ -386,7 +386,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } @@ -435,7 +435,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -446,7 +446,7 @@ describe("delete entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -479,7 +479,7 @@ describe("delete entries", () => { deleteCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToDelete.entryId}" was not found!` } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts index c50027151ad..e2040a4ebcf 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.deleteMultiple.test.ts @@ -23,10 +23,10 @@ interface Result { describe("delete multiple entries", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const reader = useCategoryReadHandler({ - path: "read/en-US" + path: "read" }); const createCategory = async (input: CreateCategoryParams) => { @@ -144,7 +144,7 @@ describe("delete multiple entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -177,7 +177,7 @@ describe("delete multiple entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.move.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.move.test.ts index 040cfc26ee7..fe6f6b33be9 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.move.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.move.test.ts @@ -19,7 +19,7 @@ interface Category { describe("move content entry to another folder", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const getCategory = async (revision: string): Promise => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts index f4cc909aca3..93d1671aad0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.restore.test.ts @@ -18,10 +18,10 @@ type Categories = CmsEntry[]; describe("restore entries", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const reader = useCategoryReadHandler({ - path: "read/en-US" + path: "read" }); const createCategory = async (data: CreateCategoryParams) => { @@ -135,7 +135,7 @@ describe("restore entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -146,7 +146,7 @@ describe("restore entries", () => { getCategory: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", message: expect.any(String) } } @@ -218,7 +218,7 @@ describe("restore entries", () => { restoreCategoryFromBin: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToRestore.entryId}" was not found!` } @@ -301,7 +301,7 @@ describe("restore entries", () => { restoreCategoryFromBin: { data: null, error: { - code: "ENTRY_NOT_FOUND", + code: "Cms/Entry/NotFound", data: null, message: `Entry "${categoryToRestore.entryId}" was not found!` } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts index 80eeb986620..2b12f897a63 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntry.withId.test.ts @@ -1,6 +1,6 @@ /** * This test determines that a user can send a custom ID when creating a content entry. - * The rest of functionality and limitations remain the same. + * The rest of the functionality and limitations remain the same. */ import { beforeEach, describe, expect, it } from "vitest"; import { setupContentModelGroup, setupContentModels } from "~tests/testHelpers/setup"; @@ -23,10 +23,10 @@ const createCategory = (input?: Partial): Category => { describe("Content entry with user defined ID", () => { const categoryManageHandler = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const productManageHandler = useProductManageHandler({ - path: "manage/en-US" + path: "manage" }); beforeEach(async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts index b5a8a4def92..7355fe0d624 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomDates.test.ts @@ -4,7 +4,7 @@ import { setupGroupAndModels } from "~tests/testHelpers/setup"; describe("content entry custom dates", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); beforeEach(async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts index c75ae87e57e..4711eb87168 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryCustomIdentities.test.s.ts @@ -5,7 +5,7 @@ import { IdentityData } from "@webiny/api-core/features/IdentityContext"; describe("content entry custom identities", () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const mockIdentityOne: IdentityData = { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryHooks.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryHooks.test.ts index ab220b1b093..477dbdbc904 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryHooks.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryHooks.test.ts @@ -7,7 +7,7 @@ import { CmsGroup, CmsModel } from "~/types"; describe("contentEntryHooks", () => { let contentModelGroup: CmsGroup; - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, @@ -401,99 +401,4 @@ describe("contentEntryHooks", () => { expect(pubSubTracker.isExecutedOnce("contentEntry:beforeUnpublish")).toEqual(true); expect(pubSubTracker.isExecutedOnce("contentEntry:afterUnpublish")).toEqual(true); }); - - it("should execute hooks on get and list", async () => { - const { createCategory, getCategory, listCategories } = useCategoryManageHandler({ - ...manageOpts, - plugins: [assignEntryEvents()] - }); - - const [createResponse] = await createCategory({ - data: { - title: "category", - slug: "category" - } - }); - - const category = createResponse.data.createCategory.data; - const { id } = category; - - pubSubTracker.reset(); - - const [getResponse] = await getCategory({ - revision: id - }); - - expect(getResponse).toEqual({ - data: { - getCategory: { - data: category, - error: null - } - } - }); - - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeCreate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterCreate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeCreateRevisionFrom")).toEqual( - false - ); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterCreateRevisionFrom")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeUpdate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterUpdate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeDeleteRevision")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterDeleteRevision")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeDelete")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterDelete")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforePublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterPublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeUnpublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterUnpublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeGet")).toEqual(true); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeList")).toEqual(false); - - pubSubTracker.reset(); - const [listResponse] = await listCategories({ - where: { - id_in: [id] - } - }); - - expect(listResponse).toEqual({ - data: { - listCategories: { - data: [ - { - ...category - } - ], - meta: { - hasMoreItems: false, - totalCount: 1, - cursor: null - }, - error: null - } - } - }); - - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeCreate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterCreate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeCreateRevisionFrom")).toEqual( - false - ); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterCreateRevisionFrom")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeUpdate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterUpdate")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeDeleteRevision")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterDeleteRevision")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeDelete")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterDelete")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforePublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterPublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeUnpublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:afterUnpublish")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeGet")).toEqual(false); - expect(pubSubTracker.isExecutedOnce("contentEntry:beforeList")).toEqual(true); - }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index 2f103ac83b4..842993d1928 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -6,7 +6,7 @@ import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; import { createMockCmsEntry } from "~tests/helpers/createMockCmsEntry"; const manageOpts = { - path: "manage/en-US" + path: "manage" }; const createMetaData = () => { @@ -79,8 +79,7 @@ describe("Content Entry Meta Field", () => { }); const model: CmsModel = { ...updateModelResponse.data.updateContentModel.data, - tenant: "root", - locale: "en-US" + tenant: "root" }; return { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.clone.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.clone.test.ts index ad421ab0724..64c03cfe4d7 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.clone.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.clone.test.ts @@ -36,7 +36,7 @@ const createExpectedModel = (original: CmsModel, group?: CmsGroup) => { describe("content model - cloning", () => { const manageOpts = { - path: "manage/en-US" + path: "manage" }; const { @@ -202,11 +202,11 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_ID_EXISTS", + code: "Cms/Model/AlreadyExists", data: { - input: originalModel.modelId + modelId: originalModel.modelId }, - message: `Content model with modelId "product" already exists.` + message: `Model "${originalModel.modelId}" already exists!` } } } @@ -231,11 +231,11 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_ID_EXISTS", + code: "Cms/Model/AlreadyExists", data: { - input: originalModel.modelId + modelId: originalModel.modelId }, - message: `Content model with modelId "${originalModel.modelId}" already exists.` + message: `Model "${originalModel.modelId}" already exists!` } } } @@ -259,10 +259,8 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_SINGULAR_API_NAME_EXISTS", - data: { - input: originalModel.singularApiName - }, + code: "Cms/Model/ValidationError", + data: null, message: `Content model with singularApiName "${originalModel.singularApiName}" already exists.` } } @@ -287,10 +285,8 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_EXISTS", - data: { - input: originalModel.pluralApiName - }, + code: "Cms/Model/ValidationError", + data: null, message: `Content model with pluralApiName "${originalModel.pluralApiName}" already exists.` } } @@ -315,10 +311,8 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_EXISTS", - data: { - input: originalModel.pluralApiName - }, + code: "Cms/Model/ValidationError", + data: null, message: `Content model with pluralApiName "${originalModel.pluralApiName}" already exists.` } } @@ -343,10 +337,8 @@ describe("content model - cloning", () => { createContentModelFrom: { data: null, error: { - code: "MODEL_SINGULAR_API_NAME_EXISTS", - data: { - input: originalModel.singularApiName - }, + code: "Cms/Model/ValidationError", + data: null, message: `Content model with singularApiName "${originalModel.singularApiName}" already exists.` } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.defaultFields.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.defaultFields.test.ts index 60b2a490058..a47cb516760 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.defaultFields.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.defaultFields.test.ts @@ -10,7 +10,7 @@ const pluralApiName = "ModelWithDefaultFieldsPlural"; describe("content model default fields", () => { const { createContentModelGroupMutation, createContentModelMutation, getContentModelQuery } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); let contentModelGroup: CmsGroup; diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.images.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.images.test.ts index 64f03fe9939..148db89c066 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.images.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.images.test.ts @@ -7,7 +7,7 @@ import { createImageModel } from "~tests/contentAPI/mocks/models/images"; describe("Content Model Nested Object Images", () => { const group = createContentModelGroup(); const handler = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [new CmsGroupPlugin(group)] }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.modelId.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.modelId.test.ts index 691ced946fa..b41ac28f31e 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.modelId.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.modelId.test.ts @@ -6,7 +6,7 @@ import upperFirst from "lodash/upperFirst"; import pluralize from "pluralize"; describe("ContentModel modelId variations", () => { - const manageHandlerOpts = { path: "manage/en-US" }; + const manageHandlerOpts = { path: "manage" }; const { createContentModelGroupMutation, createContentModelMutation } = useGraphQLHandler(manageHandlerOpts); @@ -58,7 +58,7 @@ describe("ContentModel modelId variations", () => { createContentModel: { data: null, error: { - code: "MODEL_SINGULAR_API_NAME_ENDING_NOT_ALLOWED", + code: "Cms/Model/ValidationError", data: { input: singularApiName, disallowedEnding: [ @@ -100,7 +100,7 @@ describe("ContentModel modelId variations", () => { createContentModel: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_NOT_ENDING_ALLOWED", + code: "Cms/Model/ValidationError", data: { input: pluralApiName, disallowedEnding: [ diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.noFieldPlugin.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.noFieldPlugin.test.ts index 8053cf4bcb9..1b5c5127e88 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.noFieldPlugin.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.noFieldPlugin.test.ts @@ -27,9 +27,9 @@ const customFieldPlugin = (): CmsModelFieldToGraphQLPlugin => ({ }); describe("content model test no field plugin", () => { - const readHandlerOpts = { path: "read/en-US" }; - const manageHandlerOpts = { path: "manage/en-US" }; - const previewHandlerOpts = { path: "preview/en-US" }; + const readHandlerOpts = { path: "read" }; + const manageHandlerOpts = { path: "manage" }; + const previewHandlerOpts = { path: "preview" }; const { createContentModelGroupMutation, @@ -117,7 +117,7 @@ describe("content model test no field plugin", () => { updateContentModel: { data: null, error: { - code: "", + code: "Cms/Model/ValidationError", data: null, message: 'Cannot update content model because of the unknown "SOMETHING-INVALID-HERE" field.' diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.private.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.private.test.ts index d44686ef431..d0a41f11e86 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.private.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.private.test.ts @@ -36,7 +36,7 @@ const privateAuthorsModel = createPrivateModelPlugin({ describe("Private Groups and Models", function () { const manageHandlerOpts = { - path: "manage/en-US", + path: "manage", plugins: [privateGroup, privateAuthorsModel] }; diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.reservedModelIds.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.reservedModelIds.test.ts index 212c35fb966..293587faedf 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.reservedModelIds.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.reservedModelIds.test.ts @@ -4,7 +4,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; import { pubSubTracker } from "./mocks/lifecycleHooks"; describe("content model test reserved model ids", () => { - const manageHandlerOpts = { path: "manage/en-US" }; + const manageHandlerOpts = { path: "manage" }; const { createContentModelGroupMutation, createContentModelMutation } = useGraphQLHandler(manageHandlerOpts); @@ -41,7 +41,7 @@ describe("content model test reserved model ids", () => { createContentModel: { data: null, error: { - code: "MODEL_ID_NOT_ALLOWED", + code: "Cms/Model/ValidationError", data: { input: "contentModel" }, @@ -66,7 +66,7 @@ describe("content model test reserved model ids", () => { createContentModel: { data: null, error: { - code: "MODEL_ID_NOT_ALLOWED", + code: "Cms/Model/ValidationError", data: { input: "contentModelGroup" }, diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts index 22c218835f0..a28565e0229 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.test.ts @@ -41,8 +41,8 @@ const createPermissions = ({ models, groups }: { models?: string[]; groups?: str ]; describe("content model test", () => { - const readHandlerOpts = { path: "read/en-US" }; - const manageHandlerOpts = { path: "manage/en-US" }; + const readHandlerOpts = { path: "read" }; + const manageHandlerOpts = { path: "manage" }; const { createContentModelGroupMutation, @@ -366,10 +366,8 @@ describe("content model test", () => { data: null, error: { message: `Cannot delete content model "${model.modelId}" because there are existing entries.`, - code: "CONTENT_MODEL_BEFORE_DELETE_HOOK_FAILED", - data: { - model: expect.any(Object) - } + code: "Cms/Model/CannotDeleteHasEntries", + data: null } } } @@ -391,10 +389,8 @@ describe("content model test", () => { data: null, error: { message: `Cannot delete content model "${model.modelId}" because there are existing entries in the trash.`, - code: "CONTENT_MODEL_BEFORE_DELETE_HOOK_FAILED", - data: { - model: expect.any(Object) - } + code: "Cms/Model/CannotDeleteHasEntriesInTrash", + data: null } } } @@ -467,8 +463,8 @@ describe("content model test", () => { getContentModel: { data: null, error: { - message: `Content model "${modelId}" was not found!`, - code: "NOT_FOUND", + message: `Model "${modelId}" was not found!`, + code: "Cms/Model/NotFound", data: null } } @@ -493,8 +489,8 @@ describe("content model test", () => { updateContentModel: { data: null, error: { - message: `Content model "${modelId}" was not found!`, - code: "NOT_FOUND", + message: `Model "${modelId}" was not found!`, + code: "Cms/Model/NotFound", data: null } } @@ -515,8 +511,8 @@ describe("content model test", () => { deleteContentModel: { data: null, error: { - message: `Content model "${modelId}" was not found!`, - code: "NOT_FOUND", + message: `Model "${modelId}" was not found!`, + code: "Cms/Model/NotFound", data: null } } @@ -1056,8 +1052,8 @@ describe("content model test", () => { expect(response.data.getContentModel.data).toEqual(null); expect(response.data.getContentModel.error).toEqual({ - code: "NOT_AUTHORIZED", - message: `Not allowed to access content model "Test Content model instance-0".`, + code: "Cms/Model/NotAuthorized", + message: `Not allowed to access content model "testContentModel0".`, data: null }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.uniqueModelId.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.uniqueModelId.test.ts index 01a001a2dc1..4c597c325f1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.uniqueModelId.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentModel.crud.uniqueModelId.test.ts @@ -3,7 +3,7 @@ import { CmsGroup } from "~/types"; import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; describe("content model test", () => { - const manageHandlerOpts = { path: "manage/en-US" }; + const manageHandlerOpts = { path: "manage" }; const { createContentModelGroupMutation } = useGraphQLHandler(manageHandlerOpts); @@ -68,10 +68,8 @@ describe("content model test", () => { createContentModel: { data: null, error: { - code: "MODEL_ID_EXISTS", - data: { - input: "event" - }, + code: "Cms/Model/ValidationError", + data: null, message: 'Content model with modelId "event" already exists.' } } @@ -125,11 +123,9 @@ describe("content model test", () => { createContentModel: { data: null, error: { - code: "MODEL_SINGULAR_API_NAME_EXISTS", - data: { - input: "Event" - }, - message: 'Content model with singularApiName "Event" already exists.' + code: "Cms/Model/ValidationError", + data: null, + message: `Content model with singularApiName "Event" already exists.` } } } @@ -152,10 +148,8 @@ describe("content model test", () => { createContentModel: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_EXISTS", - data: { - input: "Events" - }, + code: "Cms/Model/ValidationError", + data: null, message: 'Content model with pluralApiName "Events" already exists.' } } @@ -191,10 +185,8 @@ describe("content model test", () => { createContentModel: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_EXISTS", - data: { - input: "Events" - }, + code: "Cms/Model/ValidationError", + data: null, message: 'Content model with pluralApiName "Events" already exists.' } } @@ -216,10 +208,8 @@ describe("content model test", () => { createContentModel: { data: null, error: { - code: "MODEL_PLURAL_API_NAME_EXISTS", - data: { - input: "Events" - }, + code: "Cms/Model/ValidationError", + data: null, message: 'Content model with pluralApiName "Events" already exists.' } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/deepNestedObject.test.ts b/packages/api-headless-cms/__tests__/contentAPI/deepNestedObject.test.ts index 63f5caaa6e6..4b9e156b154 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/deepNestedObject.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/deepNestedObject.test.ts @@ -59,7 +59,7 @@ describe("Cars Model Deep Nested Object Fields", () => { const handler = useGraphQLHandler({ plugins: [...createCarsModel(), onModelInitialize(tracker)], - path: "manage/en-US" + path: "manage" }); beforeEach(async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts index 071213a5a04..2a339591cd0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/dynamicZoneField.test.ts @@ -326,8 +326,8 @@ type Values = { }; describe("dynamicZone field", () => { - const manageOpts = { path: "manage/en-US" }; - const previewOpts = { path: "preview/en-US" }; + const manageOpts = { path: "manage" }; + const previewOpts = { path: "preview" }; const eventEntryContent: { beforeCreate: CmsEntry | undefined; diff --git a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts index 8eb7d300ba9..0de2b9d5231 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts @@ -27,7 +27,6 @@ const createFruitData = (counter: number) => { tenant: "root", firstPublishedOn: new Date().toISOString(), lastPublishedOn: new Date().toISOString(), - locale: "en-US", values: { name: `Fruit ${counter}`, isSomething: false, @@ -52,7 +51,7 @@ const createFruitData = (counter: number) => { }; describe("entry pagination", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const manager = useFruitManageHandler(manageOpts); const { storageOperations } = manager; @@ -63,7 +62,6 @@ describe("entry pagination", () => { const group = await setupContentModelGroup(manager); await setupContentModels(manager, group, ["fruit"]); const model = (await storageOperations.models.get({ - locale: "en-US", tenant: "root", modelId: "fruit" })) as CmsModel; diff --git a/packages/api-headless-cms/__tests__/contentAPI/export.structure.test.ts b/packages/api-headless-cms/__tests__/contentAPI/export.structure.test.ts index 7a30c3998f9..1e4fb8b8825 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/export.structure.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/export.structure.test.ts @@ -67,7 +67,7 @@ describe("export cms structure", () => { createContentModelGroupMutation, createContentModelMutation } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); const createdGroups = await insertGroups(createContentModelGroupMutation); diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts index cf08bdb8f4c..08f221135d9 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchema.test.ts @@ -28,7 +28,7 @@ const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ describe("content model test no field plugin", () => { it("prevent content model update if a backend plugin for a field does not exist", async () => { const { invoke } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [graphqlSchemaPlugin] }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts index 7381149edf1..ebc7a130781 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/extendingGqlSchemaError.test.ts @@ -13,7 +13,7 @@ const graphqlSchemaPlugin = createCmsGraphQLSchemaPlugin({ describe("invalid schema error formatting", () => { it("print invalid part of the schema", async () => { const { invoke } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [graphqlSchemaPlugin] }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts index cadb2539257..66de913c327 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts @@ -5,7 +5,7 @@ import models from "./mocks/contentModels"; import { useFruitManageHandler } from "../testHelpers/useFruitManageHandler"; describe("fieldValidations", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, createContentModelGroupMutation } = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts b/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts index fac4f1cb7f4..d26f5ecf79d 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/filtering.test.ts @@ -66,8 +66,8 @@ vi.setConfig({ }); describe("filtering", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const mainManager = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/graphQlAndQueries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/graphQlAndQueries.test.ts index c70aa68e7aa..85dab0b621a 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/graphQlAndQueries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/graphQlAndQueries.test.ts @@ -68,12 +68,12 @@ const categories = [ describe(`graphql "and" queries`, () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const { createCategory, listCategories } = manager; const { createProduct, listProducts } = useProductManageHandler({ - path: "manage/en-US" + path: "manage" }); const createProducts = async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/graphQlOrQueries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/graphQlOrQueries.test.ts index 33d38abe35f..773a392da52 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/graphQlOrQueries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/graphQlOrQueries.test.ts @@ -14,7 +14,7 @@ const categories = [ describe(`graphql "or" queries`, () => { const manager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const { createCategory, listCategories } = manager; diff --git a/packages/api-headless-cms/__tests__/contentAPI/httpOptions.test.ts b/packages/api-headless-cms/__tests__/contentAPI/httpOptions.test.ts index 6369be82192..b4e7b06bee4 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/httpOptions.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/httpOptions.test.ts @@ -13,7 +13,7 @@ if (process.env.WEBINY_ENABLE_VERSION_HEADER === "true") { describe("HTTP Options request", () => { const manageOpts = { - path: "manage/en-US", + path: "manage", plugins: [ new ContextPlugin(async () => { throw new Error("This should not register."); diff --git a/packages/api-headless-cms/__tests__/contentAPI/import.structure.test.ts b/packages/api-headless-cms/__tests__/contentAPI/import.structure.test.ts index ab560114d11..9665135088f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/import.structure.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/import.structure.test.ts @@ -12,7 +12,7 @@ describe("import cms structure", () => { listContentModelsQuery, listContentModelGroupsQuery } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); it("should return error as there are no groups to validate", async () => { @@ -135,7 +135,7 @@ describe("import cms structure", () => { it("should show errors when trying to validate groups which already exist in the system", async () => { const { validateCmsStructureMutation } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [ createCmsGroup({ id: "group-1-original", @@ -848,7 +848,7 @@ describe("import cms structure", () => { listContentModelsQuery, listContentModelGroupsQuery } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [structurePlugins] }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts index 08ea29a3558..c08806812ad 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/latestEntries.test.ts @@ -11,9 +11,9 @@ vi.setConfig({ }); describe("latest entries", function () { - const manageOpts = { path: "manage/en-US" }; - const previewOpts = { path: "preview/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const previewOpts = { path: "preview" }; + const readOpts = { path: "read" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts index eddedb68da6..c4a08797d77 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts @@ -14,7 +14,6 @@ const models: CmsModel[] = [ createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), titleFieldId: "title", - lockedFields: [], name: "Category", description: "Product category", modelId: "category", @@ -65,7 +64,6 @@ const models: CmsModel[] = [ } } ], - locale: "en-US", tenant: "root", webinyVersion: "0.0.0" } diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts index 4ffa79438aa..1a77b881398 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts @@ -96,9 +96,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Test Entry", description: "This is a test model with test entries.", modelId: "testModel", @@ -174,9 +172,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Category", description: "Product category", modelId: "category", @@ -251,9 +247,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Category Singleton", description: "Product category Singleton", modelId: "categorySingleton", @@ -354,9 +348,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Product", modelId: "product", singularApiName: "ProductApiSingular", @@ -959,9 +951,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "text", - lockedFields: [], name: "Review", description: "Product review", modelId: "review", @@ -1068,9 +1058,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "fullName", - lockedFields: [], name: "Author", description: "Author", modelId: "author", @@ -1114,9 +1102,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "name", - lockedFields: [], name: "Fruit", description: "Fruit", modelId: "fruit", @@ -1622,9 +1608,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "name", - lockedFields: [], name: "Bug", description: "Debuggable bugs", modelId: "bug", @@ -1762,9 +1746,7 @@ const models: CmsModel[] = [ { createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - locale: "en-US", titleFieldId: "title", - lockedFields: [], name: "Article", description: "Article with multiple categories", modelId: "article", @@ -1921,7 +1903,6 @@ const models: CmsModel[] = [ ], layout: [], tenant: "root", - locale: "en-US", titleFieldId: "title", description: "Wrapper model for ref field with multiple models", webinyVersion diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts index c0b8e4defe1..eec0466dcb1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts @@ -20,7 +20,6 @@ interface CmsModel extends Omit { export const pageModel: CmsModel = { tenant: "root", webinyVersion, - locale: "en-US", name: "Page", group: { id: "62f39c13ebe1d800091bf33c", @@ -32,7 +31,6 @@ export const pageModel: CmsModel = { pluralApiName: "PagesModelApiName", savedOn: "2022-12-19T19:10:02.731Z", titleFieldId: "id", - lockedFields: [], layout: [ ["kcq9kt40"], ["peeeyhtc"], diff --git a/packages/api-headless-cms/__tests__/contentAPI/model.delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/model.delete.test.ts index e05f381d990..e7cb03b761e 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/model.delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/model.delete.test.ts @@ -5,7 +5,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; import { useCategoryManageHandler } from "../testHelpers/useCategoryManageHandler"; describe("model delete", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, @@ -102,10 +102,8 @@ describe("model delete", () => { deleteContentModel: { data: null, error: { - code: "CONTENT_MODEL_BEFORE_DELETE_HOOK_FAILED", - data: { - model: expect.any(Object) - }, + code: "Cms/Model/CannotDeleteHasEntries", + data: null, message: `Cannot delete content model "category" because there are existing entries.` } } diff --git a/packages/api-headless-cms/__tests__/contentAPI/multipleValues.test.ts b/packages/api-headless-cms/__tests__/contentAPI/multipleValues.test.ts index c7e5df148e6..2bc00449a61 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/multipleValues.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/multipleValues.test.ts @@ -4,7 +4,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; import models from "./mocks/contentModels"; describe("multiple values in field", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts index 51ed7ffa767..cb0ff85e753 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts @@ -8,7 +8,6 @@ const contentModelPlugin = new CmsModelPlugin({ modelId: "product", singularApiName: "Product", pluralApiName: "Products", - locale: "en-US", tenant: "root", group: { id: "ecommerce", @@ -141,7 +140,7 @@ const GET_PRODUCT = (model: Pick) describe("content model plugins", () => { const { storageOperations } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); beforeEach(async () => { @@ -168,7 +167,7 @@ describe("content model plugins", () => { updateContentModelMutation, deleteContentModelMutation } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [contentModelPlugin] }); @@ -211,12 +210,11 @@ describe("content model plugins", () => { createContentModel: { data: null, error: { - code: "CONTENT_MODEL_CREATE_ERROR", + code: "Cms/Model/AlreadyExists", data: { modelId: "product" }, - message: - 'Cannot create "product" content model because one is already registered via a plugin.' + message: `Model "product" is already registered via a plugin.` } } } @@ -236,11 +234,11 @@ describe("content model plugins", () => { updateContentModel: { data: null, error: { - code: "CONTENT_MODEL_UPDATE_ERROR", + code: "Cms/Model/CannotUpdateCodeModel", data: { modelId: "product" }, - message: "Content models defined via plugins cannot be updated." + message: `Cannot update model "product" defined via code.` } } } @@ -254,11 +252,11 @@ describe("content model plugins", () => { deleteContentModel: { data: null, error: { - code: "CONTENT_MODEL_DELETE_ERROR", + code: "Cms/Model/CannotDeleteCodeModel", data: { modelId: "product" }, - message: "Content models defined via plugins cannot be deleted." + message: `Cannot delete model "product" defined via code.` } } } @@ -267,7 +265,7 @@ describe("content model plugins", () => { it("content model must be returned in the content models list and get queries", async () => { const { listContentModelsQuery, getContentModelQuery } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [contentModelPlugin] }); @@ -461,7 +459,7 @@ describe("content model plugins", () => { it("must be able to perform basic CRUD operations with content models registered via plugin", async () => { const { invoke } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [contentModelPlugin] }); @@ -631,7 +629,7 @@ describe("content model plugins", () => { createContentModelGroupMutation, listContentModelsQuery } = useGraphQLHandler({ - path: "manage/en-US", + path: "manage", plugins: [contentModelPlugin] }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModelsRef.test.ts b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModelsRef.test.ts index 4a5aa6f4aeb..370dd9c36e7 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModelsRef.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModelsRef.test.ts @@ -3,8 +3,6 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; import { CmsModelPlugin } from "~/plugins/CmsModelPlugin"; const pageModelPlugin = new CmsModelPlugin({ - locale: "en-US", - lockedFields: [], modelId: "page", name: "Page", description: "", @@ -84,11 +82,9 @@ const pageModelPlugin = new CmsModelPlugin({ }); const faqModelPlugin = new CmsModelPlugin({ - lockedFields: [], modelId: "faq", name: "FAQ", titleFieldId: "id", - locale: "en-US", description: "", singularApiName: "FaqModelApiNameRef", pluralApiName: "FaqsModelApiNameRefs", @@ -161,8 +157,6 @@ const faqModelPlugin = new CmsModelPlugin({ }); const faqGroupBannerModelPlugin = new CmsModelPlugin({ - locale: "en-US", - lockedFields: [], modelId: "faqGroupBanner", name: "FAQ Group Banner", tenant: "root", @@ -333,7 +327,7 @@ const faqGroupBannerModelPlugin = new CmsModelPlugin({ describe("content model plugins - nested `ref` field union types", () => { const { introspect } = useGraphQLHandler({ plugins: [pageModelPlugin, faqModelPlugin, faqGroupBannerModelPlugin], - path: "read/en-US" + path: "read" }); it("must generate valid schema for nested `ref` field union", async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts index 47901b4711e..0c424c9c4d0 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts @@ -5,7 +5,7 @@ import { useGraphQLHandler } from "../testHelpers/useGraphQLHandler"; import { useBugManageHandler } from "../testHelpers/useBugManageHandler"; describe("predefined values", () => { - const manageOpts = { path: "manage/en-US" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts index 8ede00910cb..36f3306d5b1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/refField.test.ts @@ -32,8 +32,8 @@ interface CreateAuthorParams { } describe("refField", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const mainHandler = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/references.test.ts b/packages/api-headless-cms/__tests__/contentAPI/references.test.ts index 807170c0e63..ae0791dea03 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/references.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/references.test.ts @@ -122,8 +122,8 @@ const extractReadArticle = (item: any, category?: any): Record => { }; describe("entry references", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const mainManager = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/references/publishedAndUnpublished.test.ts b/packages/api-headless-cms/__tests__/contentAPI/references/publishedAndUnpublished.test.ts index 68b4d027dd7..710d8334082 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/references/publishedAndUnpublished.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/references/publishedAndUnpublished.test.ts @@ -74,8 +74,8 @@ interface ICategoryItem { const categoryNames = ["Tech", "Health", "Space", "Food", "Science", "Sports"]; describe("published and unpublished references", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const mainManager = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts index b0f90f4328c..8a1d893d590 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts @@ -16,8 +16,8 @@ interface CreateEntryResult { } describe("Republish entries", () => { - const readOpts = { path: "read/en-US" }; - const manageOpts = { path: "manage/en-US" }; + const readOpts = { path: "read" }; + const manageOpts = { path: "manage" }; const { createContentModelMutation, @@ -75,8 +75,7 @@ describe("Republish entries", () => { }); return { ...update.data.updateContentModel.data, - tenant: "root", - locale: "en-US" + tenant: "root" }; }; @@ -145,7 +144,6 @@ describe("Republish entries", () => { entry: { id: `${id}#0001`, entryId: id, - locale: model.locale, tenant: model.tenant, webinyVersion, locked: false, diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts index d0d30793f58..97ab659fa12 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.manage.test.ts @@ -46,8 +46,8 @@ describe("MANAGE - resolvers - api key", () => { authorization: API_TOKEN }; - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts index 09ff69e6878..e90834a4b96 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.apiKey.read.test.ts @@ -37,9 +37,9 @@ describe("READ - resolvers - api key", () => { const API_TOKEN = "aToken"; const manageOpts = { - path: "manage/en-US" + path: "manage" }; - const readOpts = { path: "read/en-US", permissions: [] }; + const readOpts = { path: "read", permissions: [] }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts index ca06df9d1ee..8d0dd59bf42 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts @@ -45,8 +45,8 @@ vi.setConfig({ describe("MANAGE - Resolvers", () => { let contentModelGroup: CmsGroup; - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const { createContentModelMutation, createContentModelGroupMutation } = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts index ad903e19afe..1936a1b6f7f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.read.test.ts @@ -80,8 +80,8 @@ vi.setConfig({ describe.sequential("READ - Resolvers", () => { let contentModelGroup: CmsGroup; - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/revisionIdScalar.test.ts b/packages/api-headless-cms/__tests__/contentAPI/revisionIdScalar.test.ts index dce8cd059f5..267cf91512b 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/revisionIdScalar.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/revisionIdScalar.test.ts @@ -5,7 +5,7 @@ import type { CmsGroup, CmsModel } from "~/types"; import { useArticleManageHandler } from "~tests/testHelpers/useArticleManageHandler"; describe("revision id scalar", () => { - const manageHandlerOpts = { path: "manage/en-US" }; + const manageHandlerOpts = { path: "manage" }; const { createContentModelGroupMutation, createContentModelMutation, createArticle } = useArticleManageHandler(manageHandlerOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts index 377cdf98441..b46870f75f8 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/richTextField.test.ts @@ -32,8 +32,8 @@ const richTextMock = [ ]; describe("richTextField", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const { createContentModelMutation, diff --git a/packages/api-headless-cms/__tests__/contentAPI/search.test.ts b/packages/api-headless-cms/__tests__/contentAPI/search.test.ts index ad674e41999..dae8cdace8a 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/search.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/search.test.ts @@ -6,10 +6,10 @@ import { toSlug } from "~/utils/toSlug"; describe("search", () => { const categoryManager = useCategoryManageHandler({ - path: "manage/en-US" + path: "manage" }); const fruitManager = useFruitManageHandler({ - path: "manage/en-US" + path: "manage" }); const { createFruit, listFruits } = fruitManager; diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts index 87ded230442..45fb9f57e20 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts @@ -62,6 +62,7 @@ describe("Write Permissions Checks", () => { entries: { rwd: "r" } }); + const p = permissions.getPermissions(); const { manage: manageApiB } = useTestModelHandler({ identity: identityB, permissions: permissions.getPermissions() diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/delete.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/delete.test.ts index 222c7403962..2661056ba67 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/delete.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/delete.test.ts @@ -39,6 +39,7 @@ describe("Delete Permissions Checks", () => { }); expectNotAuthorized(modelDeletion.data.deleteContentModel, { + code: "Cms/Model/NotAuthorized", message: "Not allowed to access content models." }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/write.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/write.test.ts index d15f6f01fea..ab03d9eb97b 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/write.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentModels/write.test.ts @@ -35,6 +35,7 @@ describe("Write Permissions Checks", () => { }); expectNotAuthorized(notCreatedModel.data.createContentModel, { + code: "Cms/Model/NotAuthorized", message: "Not allowed to access content models." }); @@ -108,6 +109,7 @@ describe("Write Permissions Checks", () => { }); expectNotAuthorized(notUpdatedModel.data.updateContentModel, { + code: "Cms/Model/NotAuthorized", message: "Not allowed to access content models." }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/sorting.test.ts b/packages/api-headless-cms/__tests__/contentAPI/sorting.test.ts index e8461cfef46..03d57a91ce5 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/sorting.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/sorting.test.ts @@ -75,8 +75,8 @@ vi.setConfig({ }); describe("sorting + cursor", () => { - const manageOpts = { path: "manage/en-US" }; - const readOpts = { path: "read/en-US" }; + const manageOpts = { path: "manage" }; + const readOpts = { path: "read" }; const mainManager = useGraphQLHandler(manageOpts); diff --git a/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts b/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts index 70b98e48591..c5de276180f 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/storageTransform.test.ts @@ -7,7 +7,7 @@ import { createCategoryFactory } from "~tests/filtering/product/category"; describe("storage transform for complex entries", () => { const managerOptions = { - path: "manage/en-US" + path: "manage" }; const productManager = useProductManageHandler(managerOptions); diff --git a/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts b/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts index dd1a5ff992d..9af877aabb7 100644 --- a/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts +++ b/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts @@ -30,7 +30,6 @@ export const pageEntry = { folderId: "667d2ca4fcae6a000ac38af5#0001" }, revisionSavedOn: "2024-07-05T09:18:51.860Z", - locale: "en-US", savedOn: "2024-07-05T09:18:51.860Z", values: { pageTitle: "From the Playground", diff --git a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts index 2f239a8b96f..6846159e3b6 100644 --- a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts +++ b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts @@ -596,7 +596,6 @@ export const createModel = (base?: Partial>) return [field.id]; }), webinyVersion: "5.50.0", - locale: "en-US", tenant: "root", ...(base || {}), fields @@ -916,7 +915,6 @@ const createBaseEntry = (values: Record): CmsEntry => { displayName: "Admin User" }, modelId: "test", - locale: "en-US", tenant: "root", meta: {}, locked: false, diff --git a/packages/api-headless-cms/__tests__/filtering/product.conditional.test.ts b/packages/api-headless-cms/__tests__/filtering/product.conditional.test.ts index 672762ed6fc..9e1f60adf6b 100644 --- a/packages/api-headless-cms/__tests__/filtering/product.conditional.test.ts +++ b/packages/api-headless-cms/__tests__/filtering/product.conditional.test.ts @@ -9,7 +9,7 @@ import { createGetProduct } from "./product/getProductFactory"; describe("complex product conditional filtering", () => { const options = { - path: "manage/en-US" + path: "manage" }; const categoryManager = useCategoryManageHandler(options); diff --git a/packages/api-headless-cms/__tests__/filtering/product.nestedObject.test.ts b/packages/api-headless-cms/__tests__/filtering/product.nestedObject.test.ts index ba6be000d82..058e6323253 100644 --- a/packages/api-headless-cms/__tests__/filtering/product.nestedObject.test.ts +++ b/packages/api-headless-cms/__tests__/filtering/product.nestedObject.test.ts @@ -9,7 +9,7 @@ import { createGetProduct } from "./product/getProductFactory"; describe("complex product nestedObject filtering", () => { const options = { - path: "manage/en-US" + path: "manage" }; const categoryManager = useCategoryManageHandler(options); diff --git a/packages/api-headless-cms/__tests__/graphql/numbersModel.test.ts b/packages/api-headless-cms/__tests__/graphql/numbersModel.test.ts index 9fd418ac4f5..d64edddb5f7 100644 --- a/packages/api-headless-cms/__tests__/graphql/numbersModel.test.ts +++ b/packages/api-headless-cms/__tests__/graphql/numbersModel.test.ts @@ -25,7 +25,7 @@ const float20Value = 0.02753098762000982458; describe("numbers model", () => { const handler = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); beforeEach(async () => { diff --git a/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts b/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts index bd256cf888e..f2483d70fd4 100644 --- a/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts +++ b/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts @@ -3,13 +3,11 @@ import type { CmsModel } from "~/types"; export const createObjectMockModel = (): CmsModel => { return { tenant: "root", - locale: "en-US", modelId: "objectModel", singularApiName: "ObjectModel", pluralApiName: "ObjectModels", name: "Object Model", titleFieldId: "titleFieldId", - lockedFields: [], createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), createdBy: { diff --git a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts index f1ca51ef0c8..cbe4bca38ca 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/entries.test.ts @@ -9,7 +9,7 @@ vi.setConfig({ describe("Entries storage operations", () => { const { storageOperations, plugins } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); /** diff --git a/packages/api-headless-cms/__tests__/storageOperations/fieldUniqueValues.test.ts b/packages/api-headless-cms/__tests__/storageOperations/fieldUniqueValues.test.ts index 4aff9007040..d1ffe0f2aa7 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/fieldUniqueValues.test.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/fieldUniqueValues.test.ts @@ -9,7 +9,7 @@ import { createStorageOperationsContext } from "~tests/storageOperations/context describe("field unique values listing", () => { const { storageOperations, plugins } = useGraphQLHandler({ - path: "manage/en-US" + path: "manage" }); /** diff --git a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts index 89261fd289a..7f36e6bb0ca 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts @@ -15,7 +15,6 @@ const webinyVersion = "0.0.0"; const baseGroup = new CmsGroupPlugin({ name: "Base group", tenant: "root", - locale: "en-US", id: "group", slug: "group", description: "", @@ -85,7 +84,6 @@ export const createPersonModel = (): CmsModel => { name: baseGroup.contentModelGroup.name }, modelId: "personEntriesModel", - locale: "en-US", tenant: "root", titleFieldId: personModelFields.name.id, fields: Object.values(personModelFields), @@ -145,7 +143,6 @@ export const createPersonEntries = async ( createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), modelId: personModel.modelId, - locale: personModel.locale, tenant: personModel.tenant, webinyVersion: personModel.webinyVersion, locked: false, @@ -181,7 +178,6 @@ export const createPersonEntries = async ( createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), modelId: personModel.modelId, - locale: personModel.locale, tenant: personModel.tenant, webinyVersion: personModel.webinyVersion, locked: false, diff --git a/packages/api-headless-cms/__tests__/types.ts b/packages/api-headless-cms/__tests__/types.ts index 00e8d5728e0..5e81996f181 100644 --- a/packages/api-headless-cms/__tests__/types.ts +++ b/packages/api-headless-cms/__tests__/types.ts @@ -7,7 +7,6 @@ export type CmsModel = Omit< | "locale" | "tenant" | "webinyVersion" - | "lockedFields" | "createdOn" | "createdBy" | "savedOn" diff --git a/packages/api-headless-cms/__tests__/validations/fields/text.ts b/packages/api-headless-cms/__tests__/validations/fields/text.ts index 36973cf7c4f..91514afe138 100644 --- a/packages/api-headless-cms/__tests__/validations/fields/text.ts +++ b/packages/api-headless-cms/__tests__/validations/fields/text.ts @@ -37,9 +37,3 @@ export const createTextFieldWithoutFieldId = createFieldFactory({ storageId: "text@fieldWithoutFieldId", fieldId: "" }); -export const createTextFieldWithoutStorageId = createFieldFactory({ - id: "fieldWithoutStorageId", - label: "Field without storageId", - storageId: "", - fieldId: "fieldWithoutStorageId" -}); diff --git a/packages/api-headless-cms/__tests__/validations/models/test.ts b/packages/api-headless-cms/__tests__/validations/models/test.ts index 02dba8a8689..c5213de5e12 100644 --- a/packages/api-headless-cms/__tests__/validations/models/test.ts +++ b/packages/api-headless-cms/__tests__/validations/models/test.ts @@ -11,7 +11,6 @@ export const createTestModel = (model: Partial = {}): CmsModel => { layout: [], titleFieldId: "id", tenant: "root", - locale: "en-US", group: { id: "group", name: "Group" diff --git a/packages/api-headless-cms/__tests__/validations/validateModelFields.test.ts b/packages/api-headless-cms/__tests__/validations/validateModelFields.test.ts index 6b685ffe5d3..97cacb646e2 100644 --- a/packages/api-headless-cms/__tests__/validations/validateModelFields.test.ts +++ b/packages/api-headless-cms/__tests__/validations/validateModelFields.test.ts @@ -7,8 +7,7 @@ import { createTextFieldWithDuplicatedStorageId, createTextFieldWithDuplicateFieldId, createTextFieldWithDuplicateId, - createTextFieldWithoutFieldId, - createTextFieldWithoutStorageId + createTextFieldWithoutFieldId } from "./fields/text"; import type { CmsModelField } from "./fields/types"; import { createNumberField } from "~tests/validations/fields/number"; @@ -47,7 +46,6 @@ describe("Validate model fields", () => { let textFieldWithDuplicatedId: CmsModelField; let textFieldWithDuplicatedFieldId: CmsModelField; let textFieldWithDuplicatedStorageId: CmsModelField; - let textFieldWithoutStorageId: CmsModelField; /** * Number */ @@ -56,10 +54,9 @@ describe("Validate model fields", () => { beforeEach(async () => { const { handler, tenant } = useHandler({}); context = await handler({ - path: "/cms/manage/en-US", + path: "/cms/manage", headers: { "x-webiny-cms-endpoint": "manage", - "x-webiny-cms-locale": "en-US", "x-tenant": tenant.id } }); @@ -69,7 +66,6 @@ describe("Validate model fields", () => { textFieldWithDuplicatedId = createTextFieldWithDuplicateId(); textFieldWithDuplicatedFieldId = createTextFieldWithDuplicateFieldId(); textFieldWithDuplicatedStorageId = createTextFieldWithDuplicatedStorageId(); - textFieldWithoutStorageId = createTextFieldWithoutStorageId(); // number numberField = createNumberField(); }); @@ -237,26 +233,6 @@ describe("Validate model fields", () => { }); }); - it("should assign fieldId to the storageId on locked field", async () => { - await validateModelFields({ - context, - models: [], - model: createModel({ - fields: [textField, textFieldWithoutStorageId], - layout: [[textField.id], [textFieldWithoutStorageId.id]], - lockedFields: [ - { - fieldId: textFieldWithoutStorageId.id, - multipleValues: textFieldWithoutStorageId.multipleValues, - type: textFieldWithoutStorageId.type - } - ] - }) - }); - - expect(textFieldWithoutStorageId.storageId).toEqual(textFieldWithoutStorageId.fieldId); - }); - it("should assign original field storageId to an updated one", async () => { const field: any = { ...textField, diff --git a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts index d702c54afe7..05b58ecc06e 100644 --- a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts +++ b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts @@ -220,24 +220,6 @@ export class AccessControl { return true; } - async ensureCanAccessModel(params: CanAccessModelParams = {}) { - const canAccess = await this.canAccessModel(params); - if (canAccess) { - return; - } - - if ("model" in params) { - let modelName = "(could not determine name)"; - if (params.model?.name) { - modelName = `"${params.model.name}"`; - } - - throw new NotAuthorizedError(`Not allowed to access content model ${modelName}.`); - } - - throw new NotAuthorizedError(`Not allowed to access content models.`); - } - async canAccessNonOwnedModels(params: GetModelsAccessControlListParams) { const acl = await this.getModelsAccessControlList(params); return acl.some(ace => ace.canAccessNonOwned); diff --git a/packages/api-headless-cms/src/domain/contentModel/errors.ts b/packages/api-headless-cms/src/domain/contentModel/errors.ts index 0c025f19ab8..e30f951730f 100644 --- a/packages/api-headless-cms/src/domain/contentModel/errors.ts +++ b/packages/api-headless-cms/src/domain/contentModel/errors.ts @@ -1,5 +1,6 @@ import { BaseError } from "@webiny/feature/api"; import type { CmsModel } from "~/types/index.js"; +import type { GenericRecord } from "@webiny/utils"; export class ModelNotAuthorizedError extends BaseError { override readonly code = "Cms/Model/NotAuthorized" as const; @@ -27,12 +28,18 @@ export class ModelNotFoundError extends BaseError { } } -export class ModelAlreadyExistsError extends BaseError { +interface ModelAlreadyExistsParams { + modelId: string; + message?: string; +} + +export class ModelAlreadyExistsError extends BaseError<{ modelId: string }> { override readonly code = "Cms/Model/AlreadyExists" as const; - constructor(modelId: string) { + constructor(params: ModelAlreadyExistsParams) { super({ - message: `Model "${modelId}" already exists!` + message: params.message ?? `Model "${params.modelId}" already exists!`, + data: { modelId: params.modelId } }); } } @@ -47,32 +54,40 @@ export class ModelPersistenceError extends BaseError { } } -export class ModelValidationError extends BaseError { +export class ModelValidationError extends BaseError | undefined> { override readonly code = "Cms/Model/ValidationError" as const; - constructor(message: string) { + constructor(params: { message: string; data?: GenericRecord } | string) { + if (typeof params === "string") { + super({ message: params }); + return; + } + super({ - message + message: params.message, + data: params.data ?? undefined }); } } -export class ModelCannotUpdateCodeDefinedError extends BaseError { +export class ModelCannotUpdateCodeModelError extends BaseError<{ modelId: string }> { override readonly code = "Cms/Model/CannotUpdateCodeModel" as const; constructor(modelId: string) { super({ - message: `Cannot update model "${modelId}" defined via code` + message: `Cannot update model "${modelId}" defined via code.`, + data: { modelId } }); } } -export class ModelCannotDeleteCodeDefinedError extends BaseError { +export class ModelCannotDeleteCodeModelError extends BaseError<{ modelId: string }> { override readonly code = "Cms/Model/CannotDeleteCodeModel" as const; constructor(modelId: string) { super({ - message: `Cannot delete code-defined model "${modelId}"` + message: `Cannot delete model "${modelId}" defined via code.`, + data: { modelId } }); } } @@ -86,3 +101,23 @@ export class ModelSlugTakenError extends BaseError { }); } } + +export class ModelCannotDeleteHasEntriesError extends BaseError { + override readonly code = "Cms/Model/CannotDeleteHasEntries" as const; + + constructor(modelId: string) { + super({ + message: `Cannot delete content model "${modelId}" because there are existing entries.` + }); + } +} + +export class ModelCannotDeleteHasEntriesInTrashError extends BaseError { + override readonly code = "Cms/Model/CannotDeleteHasEntriesInTrash" as const; + + constructor(modelId: string) { + super({ + message: `Cannot delete content model "${modelId}" because there are existing entries in the trash.` + }); + } +} diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts index bb3fb498166..cfee96fceed 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/ListLatestEntriesUseCase.ts @@ -23,6 +23,7 @@ class ListLatestEntriesUseCaseImpl implements UseCaseAbstraction.Interface { const { where, ...rest } = params || {}; return await this.listEntriesUseCase.execute(model, { + sort: ["createdOn_DESC"], ...rest, where: { ...where, diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts index c9ed2458c8d..77ecdc7839d 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts @@ -4,7 +4,7 @@ import { CreateModelRepository as RepositoryAbstraction } from "./abstractions.j import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; import { ListModelsUseCase } from "~/features/contentModel/ListModels/index.js"; -import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -77,41 +77,38 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { try { validateModelIdAllowed({ model }); } catch (error) { - return Result.fail(new ModelValidationError((error as Error).message)); + return Result.fail( + new ModelValidationError({ + message: error.message, + data: error.data + }) + ); } // Validate API name endings try { validateEndingAllowed({ model }); } catch (error) { - return Result.fail(new ModelValidationError((error as Error).message)); - } - - // Validate modelId uniqueness (database) - const existingById = await this.storageOperations.models.list({ - where: { - tenant: tenant.id, - modelId: model.modelId - } - }); - - if (existingById.length > 0) { - return Result.fail(new ModelSlugTakenError(model.modelId)); + return Result.fail( + new ModelValidationError({ + message: error.message, + data: error.data + }) + ); } // Check for plugin model conflicts const pluginModels = await this.pluginModelsProvider.list(tenant.id); const pluginModelConflict = pluginModels.find(pm => { - return ( - pm.modelId === model.modelId || - pm.singularApiName === model.singularApiName || - pm.pluralApiName === model.pluralApiName - ); + return pm.modelId === model.modelId; }); if (pluginModelConflict) { return Result.fail( - new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) + new ModelAlreadyExistsError({ + modelId, + message: `Model "${modelId}" is already registered via a plugin.` + }) ); } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts index 086d3ddfb1e..4bef02c2d7e 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts @@ -93,7 +93,7 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { const model: CmsModel = { ...data, - modelId: "", // Will be set by repository + modelId: data.modelId ?? "", // Will be set by repository tenant: tenant.id, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), @@ -103,7 +103,7 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { type: identity.type }, webinyVersion: this.cmsContext.WEBINY_VERSION, - description: data.description || null, + description: data.description || "", group: { id: group.id, name: group.name diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts index 793077f6004..9fb74489fe7 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts @@ -6,7 +6,7 @@ import { type ModelSlugTakenError, ModelNotAuthorizedError, type ModelValidationError, - type ModelPersistenceError + type ModelPersistenceError, ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, @@ -23,7 +23,8 @@ export interface ICreateModelUseCase { export interface ICreateModelUseCaseErrors { notAuthorized: ModelNotAuthorizedError; validation: ModelValidationError; - alreadyExists: ModelSlugTakenError; + slugTaken: ModelSlugTakenError; + alreadyExists: ModelAlreadyExistsError; persistence: ModelPersistenceError; groupNotFound: GroupNotFoundError; // Reused from Group domain groupNotAccessible: GroupNotAuthorizedError; // Reused from Group domain @@ -46,7 +47,7 @@ export interface ICreateModelRepository { } export interface ICreateModelRepositoryErrors { - alreadyExists: ModelSlugTakenError; + alreadyExists: ModelAlreadyExistsError; validation: ModelValidationError; persistence: ModelPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts index f330e375618..0e1968f5d91 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromRepository.ts @@ -4,7 +4,7 @@ import { CreateModelFromRepository as RepositoryAbstraction } from "./abstractio import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; -import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -88,15 +88,14 @@ class CreateModelFromRepositoryImpl implements RepositoryAbstraction.Interface { } // Validate modelId uniqueness (database) - const existingById = await this.storageOperations.models.list({ - where: { - tenant: tenant.id, - modelId: model.modelId - } - }); + const modelsResult = await this.modelsFetcher.fetchAll(); + if (modelsResult.isFail()) { + return Result.fail(new ModelPersistenceError(modelsResult.error)); + } - if (existingById.length > 0) { - return Result.fail(new ModelSlugTakenError(model.modelId)); + const existingModel = modelsResult.value.find(m => m.modelId === modelId); + if (existingModel) { + return Result.fail(new ModelAlreadyExistsError({ modelId })); } // Check for plugin model conflicts @@ -111,19 +110,13 @@ class CreateModelFromRepositoryImpl implements RepositoryAbstraction.Interface { if (pluginModelConflict) { return Result.fail( - new ModelSlugTakenError(`${model.singularApiName}/${model.pluralApiName}`) + new ModelAlreadyExistsError({ + modelId, + message: `Model "${modelId}" is already registered via a plugin.` + }) ); } - // Get all models for further validation - const modelsResult = await this.cmsContext.security.withoutAuthorization(async () => { - return await this.modelsFetcher.fetchAll(); - }); - - if (modelsResult.isFail()) { - return Result.fail(new ModelPersistenceError(modelsResult.error)); - } - const models = modelsResult.value; try { diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts index 2054fff1028..92fa76c6141 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts @@ -7,7 +7,7 @@ import { ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, - type ModelPersistenceError + type ModelPersistenceError, ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, @@ -28,7 +28,7 @@ export interface ICreateModelFromUseCaseErrors { notFound: ModelNotFoundError; notAuthorized: ModelNotAuthorizedError; validation: ModelValidationError; - alreadyExists: ModelSlugTakenError; + alreadyExists: ModelAlreadyExistsError; persistence: ModelPersistenceError; groupNotFound: GroupNotFoundError; groupNotAccessible: GroupNotAuthorizedError; @@ -53,7 +53,7 @@ export interface ICreateModelFromRepository { } export interface ICreateModelFromRepositoryErrors { - alreadyExists: ModelSlugTakenError; + alreadyExists: ModelAlreadyExistsError; validation: ModelValidationError; persistence: ModelPersistenceError; } diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts index 0bb12e09a38..d3312ec3249 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelRepository.ts @@ -1,11 +1,11 @@ import { Result } from "@webiny/feature/api"; import { DeleteModelRepository as RepositoryAbstraction } from "./abstractions.js"; -import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; -import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; -import { ModelValidationError } from "~/domain/contentModel/errors.js"; +import { ModelCache, ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; +import { + ModelCannotDeleteCodeModelError, + ModelPersistenceError +} from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; -import { CmsContext } from "~/features/shared/abstractions.js"; -import { CmsModelPlugin } from "~/plugins/CmsModelPlugin.js"; import type { CmsModel } from "~/types/index.js"; /** @@ -21,23 +21,20 @@ import type { CmsModel } from "~/types/index.js"; class DeleteModelRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private modelCache: ModelCache.Interface, - private storageOperations: StorageOperations.Interface, - private cmsContext: CmsContext.Interface + private modelsFetcher: ModelsFetcher.Interface, + private storageOperations: StorageOperations.Interface ) {} async execute(model: CmsModel): Promise> { try { - // Check if model is defined via plugin (core domain rule) - const modelPlugin = this.cmsContext.plugins - .byType(CmsModelPlugin.type) - .find(item => item.contentModel.modelId === model.modelId); + // Check if this is a plugin model + const existingModelResult = await this.modelsFetcher.fetchById(model.modelId); + if (existingModelResult.isFail()) { + return Result.fail(new ModelPersistenceError(existingModelResult.error)); + } - if (modelPlugin) { - return Result.fail( - new ModelValidationError( - "Content models defined via plugins cannot be deleted." - ) - ); + if (existingModelResult.value.isPlugin) { + return Result.fail(new ModelCannotDeleteCodeModelError(model.modelId)); } // Delete from storage @@ -55,5 +52,5 @@ class DeleteModelRepositoryImpl implements RepositoryAbstraction.Interface { export const DeleteModelRepository = RepositoryAbstraction.createImplementation({ implementation: DeleteModelRepositoryImpl, - dependencies: [ModelCache, StorageOperations, CmsContext] + dependencies: [ModelCache, ModelsFetcher, StorageOperations] }); diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts index 2eb843c4bed..bd00b160b36 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/DeleteModelWithEntryCleanup.ts @@ -2,7 +2,13 @@ import { Result } from "@webiny/feature/api"; import { DeleteModelUseCase } from "./abstractions.js"; import { CmsContext } from "~/features/shared/abstractions.js"; import { CMS_MODEL_SINGLETON_TAG } from "~/constants.js"; -import { ModelPersistenceError, ModelValidationError } from "~/domain/contentModel/errors.js"; +import { + ModelCannotDeleteHasEntriesError, + ModelCannotDeleteHasEntriesInTrashError, + ModelPersistenceError, + ModelValidationError +} from "~/domain/contentModel/errors.js"; +import { CmsModel } from "~/types/model.js"; /** * DeleteModelWithEntryCleanup - Decorator that handles entry cleanup/validation before deletion. @@ -38,20 +44,15 @@ class DeleteModelWithEntryCleanupImpl implements DeleteModelUseCase.Interface { ) ); } - } else { - const hasEntriesResult = await this.checkForExistingEntries(model); - if (hasEntriesResult.isFail()) { - return Result.fail(hasEntriesResult.error); - } - const hasEntries = hasEntriesResult.value; - if (hasEntries) { - return Result.fail( - new ModelValidationError( - `Cannot delete content model "${model.modelId}" because there are existing entries.` - ) - ); - } + // Proceed with the actual deletion + return this.decoratee.execute(modelId); + } + + // Regular models + const canDelete = await this.canDelete(model); + if (canDelete.isFail()) { + return Result.fail(canDelete.error); } // Proceed with the actual deletion @@ -82,9 +83,16 @@ class DeleteModelWithEntryCleanupImpl implements DeleteModelUseCase.Interface { } } - private async checkForExistingEntries( - model: any - ): Promise> { + private async canDelete( + model: CmsModel + ): Promise< + Result< + boolean, + | ModelCannotDeleteHasEntriesError + | ModelCannotDeleteHasEntriesInTrashError + | ModelPersistenceError + > + > { try { // Check for latest entries const [latestEntries] = await this.cmsContext.cms.listLatestEntries(model, { @@ -92,7 +100,7 @@ class DeleteModelWithEntryCleanupImpl implements DeleteModelUseCase.Interface { }); if (latestEntries.length > 0) { - return Result.ok(true); + return Result.fail(new ModelCannotDeleteHasEntriesError(model.modelId)); } // Check for deleted entries (trash) @@ -100,10 +108,14 @@ class DeleteModelWithEntryCleanupImpl implements DeleteModelUseCase.Interface { limit: 1 }); - return Result.ok(deletedEntries.length > 0); + if (deletedEntries.length > 0) { + return Result.fail(new ModelCannotDeleteHasEntriesInTrashError(model.modelId)); + } } catch (error) { return Result.fail(new ModelPersistenceError(error)); } + + return Result.ok(true); } } diff --git a/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts index 9a74cc36c82..f348af46cff 100644 --- a/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/DeleteModel/abstractions.ts @@ -5,7 +5,10 @@ import { ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, - type ModelPersistenceError + type ModelPersistenceError, + ModelCannotDeleteCodeModelError, + ModelCannotDeleteHasEntriesError, + ModelCannotDeleteHasEntriesInTrashError } from "~/domain/contentModel/errors.js"; /** @@ -20,6 +23,9 @@ export interface IDeleteModelUseCaseErrors { notAuthorized: ModelNotAuthorizedError; persistence: ModelPersistenceError; validation: ModelValidationError; + codeModel: ModelCannotDeleteCodeModelError; + hasEntries: ModelCannotDeleteHasEntriesError; + hasEntriesInTrash: ModelCannotDeleteHasEntriesInTrashError; } type UseCaseError = IDeleteModelUseCaseErrors[keyof IDeleteModelUseCaseErrors]; @@ -41,6 +47,7 @@ export interface IDeleteModelRepository { export interface IDeleteModelRepositoryErrors { validation: ModelValidationError; persistence: ModelPersistenceError; + codeModel: ModelCannotDeleteCodeModelError; } type RepositoryError = IDeleteModelRepositoryErrors[keyof IDeleteModelRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts index 5dc224a244c..2e967790bd4 100644 --- a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelRepository.ts @@ -15,7 +15,6 @@ class GetModelRepositoryImpl implements RepositoryAbstraction.Interface { constructor(private modelsFetcher: ModelsFetcher.Interface) {} async execute(modelId: string): Promise> { - // Use ModelsFetcher which handles all caching, access control, and merging const result = await this.modelsFetcher.fetchById(modelId); if (result.isFail()) { diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts index f0465579d93..b93bd09d978 100644 --- a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsRepository.ts @@ -23,7 +23,6 @@ class ListModelsRepositoryImpl implements RepositoryAbstraction.Interface { const includePrivate = params?.includePrivate !== false; // defaults to true const includePlugins = params?.includePlugins !== false; // defaults to true - // Use ModelsFetcher which handles all caching, access control, and merging const result = await this.modelsFetcher.fetchAll(); if (result.isFail()) { diff --git a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts index df207f0b8c3..43c9550f36d 100644 --- a/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/ListModels/ListModelsUseCase.ts @@ -5,6 +5,7 @@ import { AccessControl } from "~/features/shared/abstractions.js"; import type { CmsModel } from "~/types/index.js"; import type { ICmsModelListParams } from "~/types/index.js"; import { ModelNotAuthorizedError } from "~/domain/contentModel/errors.js"; +import { filterAsync } from "~/utils/filterAsync.js"; /** * ListModelsUseCase - Retrieves all content models. @@ -20,7 +21,9 @@ class ListModelsUseCaseImpl implements UseCaseAbstraction.Interface { private accessControl: AccessControl.Interface ) {} - async execute(params?: ICmsModelListParams): Promise> { + async execute( + params?: ICmsModelListParams + ): Promise> { // Initial access control check (no specific model yet) const canAccess = await this.accessControl.canAccessModel({ rwd: "r" }); if (!canAccess) { @@ -35,7 +38,12 @@ class ListModelsUseCaseImpl implements UseCaseAbstraction.Interface { return result; } - return Result.ok(result.value); + // Model-specific access control check + const filteredModels = await filterAsync(result.value, model => { + return this.accessControl.canAccessModel({ model, rwd: "r" }); + }); + + return Result.ok(filteredModels); } } diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts index 1ed4b56294a..9eb72095ff2 100644 --- a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelRepository.ts @@ -2,7 +2,7 @@ import { Result } from "@webiny/feature/api"; import { UpdateModelRepository as RepositoryAbstraction } from "./abstractions.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; import { ModelsFetcher } from "~/features/contentModel/shared/abstractions.js"; -import { ModelSlugTakenError } from "~/domain/contentModel/errors.js"; +import { ModelCannotUpdateCodeModelError } from "~/domain/contentModel/errors.js"; import { ModelPersistenceError } from "~/domain/contentModel/errors.js"; import { ModelValidationError } from "~/domain/contentModel/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; @@ -56,6 +56,16 @@ class UpdateModelRepositoryImpl implements RepositoryAbstraction.Interface { const allModels = modelsResult.value; const models = allModels.filter(m => m.modelId !== model.modelId); + // Check if this is a plugin model + const existingModelResult = await this.modelsFetcher.fetchById(model.modelId); + if (existingModelResult.isFail()) { + return Result.fail(new ModelPersistenceError(existingModelResult.error)); + } + + if (existingModelResult.value.isPlugin) { + return Result.fail(new ModelCannotUpdateCodeModelError(model.modelId)); + } + // Validate uniqueness try { for (const existingModel of models) { diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts index 67a5d50570e..b0610367499 100644 --- a/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts @@ -7,7 +7,7 @@ import { ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, - type ModelPersistenceError + type ModelPersistenceError, ModelCannotUpdateCodeModelError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, @@ -27,6 +27,7 @@ export interface IUpdateModelUseCaseErrors { validation: ModelValidationError; alreadyExists: ModelSlugTakenError; persistence: ModelPersistenceError; + updateCodeModel: ModelCannotUpdateCodeModelError; groupNotFound: GroupNotFoundError; groupNotAccessible: GroupNotAuthorizedError; } @@ -51,6 +52,7 @@ export interface IUpdateModelRepositoryErrors { alreadyExists: ModelSlugTakenError; validation: ModelValidationError; persistence: ModelPersistenceError; + updateCodeModel: ModelCannotUpdateCodeModelError; } type RepositoryError = IUpdateModelRepositoryErrors[keyof IUpdateModelRepositoryErrors]; diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts index c00ed2491d7..4f7ad2a8921 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts @@ -1,12 +1,12 @@ import { Result } from "@webiny/feature/api"; -import { ModelCache, ModelsFetcher as FetcherAbstraction } from "~/features/contentModel/shared/abstractions.js"; +import { + ModelCache, + ModelsFetcher as FetcherAbstraction +} from "~/features/contentModel/shared/abstractions.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/abstractions.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; -import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { ModelNotFoundError, ModelPersistenceError } from "~/domain/contentModel/errors.js"; -import { filterAsync } from "~/utils/filterAsync.js"; import { createCacheKey } from "~/utils/index.js"; import { ensureTypeTag } from "~/domain/contentModel/ensureTypeTag.js"; import type { CmsModel } from "~/types/index.js"; @@ -25,9 +25,7 @@ class ModelsFetcherImpl implements FetcherAbstraction.Interface { private modelCache: ModelCache.Interface, private pluginModelsProvider: PluginModelsProvider.Interface, private storageOperations: StorageOperations.Interface, - private accessControl: AccessControl.Interface, - private tenantContext: TenantContext.Interface, - private identityContext: IdentityContext.Interface + private tenantContext: TenantContext.Interface ) {} async fetchAll(): Promise> { @@ -36,10 +34,7 @@ class ModelsFetcherImpl implements FetcherAbstraction.Interface { // Create a cache key based on tenant + identity const cacheKey = createCacheKey({ - tenant: tenant.id, - identity: this.identityContext.isAuthorizationEnabled() - ? this.identityContext.getIdentity().id - : undefined + tenant: tenant.id }); // Try to get from cache first @@ -72,47 +67,23 @@ class ModelsFetcherImpl implements FetcherAbstraction.Interface { const pluginModels = await this.pluginModelsProvider.list(tenant); // 2. Fetch database models (with caching) - const dbCacheKey = createCacheKey({ tenant }); - const databaseModels = await this.modelCache.getOrSet(dbCacheKey, () => { + const dbCacheKey = createCacheKey({ tenant, id: "storage" }); + const databaseModels = await this.modelCache.getOrSet(dbCacheKey, async () => { return this.storageOperations.models.list({ where: { tenant } }); }); - // 3. Apply access control to database models (with caching) - const filteredCacheKey = createCacheKey({ - dbCacheKey: dbCacheKey.get(), - identity: this.identityContext.isAuthorizationEnabled() - ? this.identityContext.getIdentity()?.id - : undefined - }); - - const filteredDatabaseModels = await this.modelCache.getOrSet(filteredCacheKey, () => { - return filterAsync(databaseModels, async (model: CmsModel) => { - if (!model) { - return false; - } - return this.accessControl.canAccessModel({ model }); - }); - }); - - // 4. Ensure type tags on database models - const taggedDatabaseModels = filteredDatabaseModels.map(model => { + // 3. Ensure type tags on database models + const taggedDatabaseModels = databaseModels.map(model => { model.tags = ensureTypeTag(model); return model; }); - // 5. Merge plugin + database models - return [...pluginModels, ...taggedDatabaseModels]; + // 4. Return merged models. + return [...taggedDatabaseModels, ...pluginModels]; } } export const ModelsFetcher = FetcherAbstraction.createImplementation({ implementation: ModelsFetcherImpl, - dependencies: [ - ModelCache, - PluginModelsProvider, - StorageOperations, - AccessControl, - TenantContext, - IdentityContext - ] + dependencies: [ModelCache, PluginModelsProvider, StorageOperations, TenantContext] }); diff --git a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts index ac0f8a2b5a7..1ccbc56a62d 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/ListGroups/ListGroupsRepository.ts @@ -79,8 +79,8 @@ class ListGroupsRepositoryImpl implements RepositoryAbstraction.Interface { } ); - // 4. Merge plugin + database groups - return [...pluginGroups, ...filteredDatabaseGroups]; + // 4. Merge groups + return [...filteredDatabaseGroups, ...pluginGroups]; } } diff --git a/packages/api-headless-cms/src/graphql/schema/contentModels.ts b/packages/api-headless-cms/src/graphql/schema/contentModels.ts index 06f9fa66f8b..a87be94f39d 100644 --- a/packages/api-headless-cms/src/graphql/schema/contentModels.ts +++ b/packages/api-headless-cms/src/graphql/schema/contentModels.ts @@ -297,7 +297,6 @@ export const createModelsSchema = ({ savedOn: DateTime createdBy: CmsIdentity fields: [CmsContentModelField!]! - lockedFields: [JSON] layout: [[String!]!]! titleFieldId: String descriptionFieldId: String diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 1480d6e101c..2fc4d12e0cc 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -595,10 +595,6 @@ export interface CmsEntry { * @see CmsModel */ modelId: string; - /** - * A locale of the entry. - * @see I18NLocale.code - */ /** * A revision version of the entry. */ From 6d1dfcc970edff84a99b8487434296abba643f67 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 20:19:26 +0100 Subject: [PATCH 30/71] wip: migrate headless cms scheduler --- packages/api-elasticsearch/src/indices.ts | 9 +- .../definition/ElasticsearchIndexPlugin.ts | 28 +-- .../converters/convertersEnabled.test.ts | 48 +++-- .../src/configurations.ts | 17 +- .../tasks/MockDataCreator/MockDataCreator.ts | 30 +-- .../__tests__/handler/Handler.test.ts | 174 ++++++++++-------- .../actions/PublishHandlerAction.test.ts | 53 ++++-- .../actions/UnpublishHandlerAction.test.ts | 49 +++-- .../__tests__/handler/eventHandler.test.ts | 6 +- .../__tests__/mocks/context/useHandler.ts | 9 +- .../api-headless-cms-scheduler/package.json | 1 + .../api-headless-cms-scheduler/src/context.ts | 18 +- .../ProcessRecords/ProcessRecordsUseCase.ts | 158 ++++++++++++++++ .../features/ProcessRecords/abstractions.ts | 55 ++++++ .../actions/PublishRecordAction.ts | 98 ++++++++++ .../actions/UnpublishRecordAction.ts | 80 ++++++++ .../src/features/ProcessRecords/feature.ts | 22 +++ .../src/features/ProcessRecords/index.ts | 1 + .../src/features/Scheduler/abstractions.ts | 13 ++ .../src/features/Scheduler/index.ts | 1 + .../src/handler/Handler.ts | 113 ------------ .../handler/actions/PublishHandlerAction.ts | 75 -------- .../handler/actions/UnpublishHandlerAction.ts | 59 ------ .../src/handler/index.ts | 32 ++-- .../src/handler/types.ts | 6 - .../src/hooks/index.ts | 13 +- .../api-headless-cms-scheduler/src/index.ts | 4 +- .../src/scheduler/createScheduler.ts | 5 +- .../src/service/SchedulerService.ts | 2 +- .../api-headless-cms-scheduler/src/types.ts | 15 -- .../tsconfig.build.json | 5 + .../api-headless-cms-scheduler/tsconfig.json | 5 + yarn.lock | 1 + 33 files changed, 701 insertions(+), 504 deletions(-) create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts delete mode 100644 packages/api-headless-cms-scheduler/src/handler/Handler.ts delete mode 100644 packages/api-headless-cms-scheduler/src/handler/actions/PublishHandlerAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/handler/actions/UnpublishHandlerAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/handler/types.ts delete mode 100644 packages/api-headless-cms-scheduler/src/types.ts diff --git a/packages/api-elasticsearch/src/indices.ts b/packages/api-elasticsearch/src/indices.ts index 87b79a7921b..d63d4d2159b 100644 --- a/packages/api-elasticsearch/src/indices.ts +++ b/packages/api-elasticsearch/src/indices.ts @@ -5,15 +5,13 @@ import WebinyError from "@webiny/error"; interface IndicesPluginsParams { container: PluginsContainer; type: string; - locale: string; } const listIndicesPlugins = ({ container, - type, - locale + type }: IndicesPluginsParams): T[] => { return container.byType(type).filter(plugin => { - return plugin.canUse(locale); + return plugin.canUse(); }); }; @@ -26,8 +24,7 @@ export const getLastAddedIndexPlugin = ( `Could not find a single ElasticsearchIndexPlugin of type "${params.type}".`, "ELASTICSEARCH_INDEX_TEMPLATE_ERROR", { - type: params.type, - locale: params.locale + type: params.type } ); } diff --git a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchIndexPlugin.ts b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchIndexPlugin.ts index efb8b4f5f32..45f2351ada6 100644 --- a/packages/api-elasticsearch/src/plugins/definition/ElasticsearchIndexPlugin.ts +++ b/packages/api-elasticsearch/src/plugins/definition/ElasticsearchIndexPlugin.ts @@ -1,44 +1,22 @@ -import WebinyError from "@webiny/error"; import { Plugin } from "@webiny/plugins"; import type { ElasticsearchIndexRequestBody } from "~/types.js"; export interface ElasticsearchIndexPluginParams { - /** - * For which locales are we applying this plugin. - * Options: - * - locale codes to target specific locale - * - null for all - */ - locales?: string[]; body: ElasticsearchIndexRequestBody; } export abstract class ElasticsearchIndexPlugin extends Plugin { public readonly body: ElasticsearchIndexRequestBody; - private readonly locales: string[] | undefined; public constructor(params: ElasticsearchIndexPluginParams) { super(); - const { locales, body } = params; + const { body } = params; this.body = { ...body }; - this.locales = locales ? locales.map(locale => locale.toLowerCase()) : undefined; } - public canUse(locale: string): boolean { - if (!this.locales) { - return true; - } else if (this.locales.length === 0) { - throw new WebinyError( - "Cannot have Elasticsearch Index Template plugin with no locales defined.", - "LOCALES_ERROR", - { - body: this.body, - locales: this.locales - } - ); - } - return this.locales.includes(locale.toLowerCase()); + public canUse(): boolean { + return true; } } diff --git a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersEnabled.test.ts b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersEnabled.test.ts index 4fa44e36263..d5db7a9038e 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersEnabled.test.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersEnabled.test.ts @@ -8,10 +8,14 @@ import { createEntryRawData } from "./mocks/data"; import { configurations } from "~/configurations"; -import type { CmsEntry, CmsModel } from "@webiny/api-headless-cms/types"; +import type { CmsEntry } from "@webiny/api-headless-cms/types"; import { get } from "@webiny/db-dynamodb"; import { createPartitionKey } from "~/operations/entry/keys"; import lodashMerge from "lodash/merge"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/index.js"; describe("storage field path converters enabled", () => { const { elasticsearch, entryEntity } = useHandler(); @@ -19,7 +23,6 @@ describe("storage field path converters enabled", () => { const { index: indexName } = configurations.es({ model: { tenant: "root", - locale: "en-US", modelId: "converter" } }); @@ -39,17 +42,30 @@ describe("storage field path converters enabled", () => { }); const context = await createContext(); - const model = (await context.cms.getModel("converter")) as CmsModel; - const manager = await context.cms.getEntryManager("converter"); + const getModel = context.container.resolve(GetModelUseCase); + const getEntry = context.container.resolve(GetEntryByIdUseCase); + const createEntry = context.container.resolve(CreateEntryUseCase); + const listLatest = context.container.resolve(ListLatestEntriesUseCase); + + const modelResult = await getModel.execute("converter"); + if (modelResult.isFail()) { + throw modelResult.error; + } + + const model = modelResult.value; + + const createResult = await createEntry.execute(model, createEntryRawData()); + if (createResult.isFail()) { + throw createResult.error; + } + + const entry = createResult.value; + expect(entry).toMatchObject({ id: expect.any(String) }); - const createResult = await manager.create(createEntryRawData()); - expect(createResult).toMatchObject({ - id: expect.any(String) - }); /** * Check that we are getting everything properly out of the DynamoDB */ - const getResult = await manager.get(createResult.id); + const getResult = await getEntry.execute(model, entry.id); expect(getResult).toMatchObject({ values: createEntryRawData() }); @@ -59,12 +75,12 @@ describe("storage field path converters enabled", () => { /** * Then check that we are getting everything properly out of the Elasticsearch, via webiny API. */ - const result = await manager.listLatest({ - where: { - id: createResult.id - } + const result = await listLatest.execute(model, { + where: { id: entry.id } }); - const [[listResult]] = result; + + const [[listResult]] = result.value; + expect(listResult).toMatchObject({ values: createEntryExpectedTransformedDatesData() }); @@ -77,7 +93,7 @@ describe("storage field path converters enabled", () => { filter: [ { term: { - ["id.keyword"]: createResult.id + ["id.keyword"]: entry.id } } ] @@ -104,7 +120,7 @@ describe("storage field path converters enabled", () => { keys: { PK: createPartitionKey({ ...model, - id: createResult.id + id: entry.id }), SK: "L" } diff --git a/packages/api-headless-cms-ddb-es/src/configurations.ts b/packages/api-headless-cms-ddb-es/src/configurations.ts index 89c68998665..34b6d0b48ca 100644 --- a/packages/api-headless-cms-ddb-es/src/configurations.ts +++ b/packages/api-headless-cms-ddb-es/src/configurations.ts @@ -14,12 +14,11 @@ interface ConfigurationsElasticsearch { } export interface CmsElasticsearchParams { - model: Pick; + model: Pick; } export interface ConfigurationsIndexSettingsParams { context: CmsContext; - model: Pick; } export interface Configurations { @@ -31,22 +30,17 @@ export interface Configurations { export const configurations: Configurations = { es({ model }) { - const { tenant, locale } = model; + const { tenant } = model; if (!tenant) { throw new WebinyError( `Missing "tenant" parameter when trying to create Elasticsearch index name.`, "TENANT_ERROR" ); - } else if (!locale) { - throw new WebinyError( - `Missing "locale" parameter when trying to create Elasticsearch index name.`, - "LOCALE_ERROR" - ); } const sharedIndex = isSharedElasticsearchIndex(); - const index = [sharedIndex ? "root" : tenant, "headless-cms", locale, model.modelId] + const index = [sharedIndex ? "root" : tenant, "headless-cms", model.modelId] .join("-") .toLowerCase(); @@ -61,11 +55,10 @@ export const configurations: Configurations = { index: prefix + index }; }, - indexSettings: ({ context, model }) => { + indexSettings: ({ context }) => { const plugin = getLastAddedIndexPlugin({ container: context.plugins, - type: CmsEntryElasticsearchIndexPlugin.type, - locale: model.locale + type: CmsEntryElasticsearchIndexPlugin.type }); return plugin ? plugin.body : {}; diff --git a/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts b/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts index c0354048b2b..6f7bdb866bc 100644 --- a/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts +++ b/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts @@ -6,6 +6,8 @@ import { createWaitUntilHealthy } from "@webiny/api-elasticsearch/utils/waitUnti import type { Context } from "~/types.js"; import { ElasticsearchCatClusterHealthStatus } from "@webiny/api-elasticsearch/operations/types.js"; import { mdbid } from "@webiny/utils"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; export class MockDataCreator< C extends Context, @@ -23,13 +25,16 @@ export class MockDataCreator< }); } - let manager: CmsModelManager; - try { - manager = await context.cms.getEntryManager("cars"); - } catch (ex) { - return response.error(ex); + const getModel = context.container.resolve(GetModelUseCase); + const createEntry = context.container.resolve(CreateEntryUseCase); + + const modelResult = await getModel.execute("cars"); + if (modelResult.isFail()) { + return response.error(modelResult.error); } + const model = modelResult.value; + const healthCheck = createWaitUntilHealthy(context.elasticsearch, { waitingTimeStep: 20, maxWaitingTime: 150, @@ -94,13 +99,14 @@ export class MockDataCreator< } } const taskId = params.store.getTask().id; - try { - await manager.create({ - id: `${taskId}${mdbid()}`, - ...mockData - }); - } catch (ex) { - return response.error(ex); + + const createResult = await createEntry.execute(model, { + id: `${taskId}${mdbid()}`, + ...mockData + }); + + if (createResult.isFail()) { + return response.error(createResult.error); } } diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts index 0a46bf49c1f..d5f7767cda6 100644 --- a/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts +++ b/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts @@ -1,20 +1,25 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { Handler } from "~/handler/Handler.js"; import { useHandler } from "~tests/mocks/context/useHandler.js"; import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { ScheduleContext } from "~/types.js"; -import { NotFoundError } from "@webiny/handler-graphql"; import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import type { CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import type { CmsContext, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; import { createScheduleRecordId, createScheduleRecordIdWithVersion } from "~/scheduler/createScheduleRecordId.js"; import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; import { dateToISOString } from "~/scheduler/dates.js"; -import { UnpublishHandlerAction } from "~/handler/actions/UnpublishHandlerAction.js"; -import { PublishHandlerAction } from "~/handler/actions/PublishHandlerAction.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import { ProcessRecordsUseCase } from "~/features/ProcessRecords/ProcessRecordsUseCase.js"; +import { + ProcessRecordsUseCase as ProcessRecordsAbstraction, + RecordAction +} from "~/features/ProcessRecords/index.js"; +import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; +import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; +import { SchedulerFactory } from "~/features/Scheduler/abstractions"; const createEventScheduleRecordId = (targetId: string): string => { return `${createScheduleRecordIdWithVersion(targetId)}`; @@ -23,7 +28,7 @@ const createEventScheduleRecordId = (targetId: string): string => { describe("Handler", () => { const targetId = "target-id#0001"; - let context: ScheduleContext; + let context: CmsContext; beforeEach(async () => { const contextHandler = useHandler({ @@ -37,49 +42,58 @@ describe("Handler", () => { const createScheduleEntry = async ( values: Omit ): Promise> => { - const scheduleEntryManager = - await context.cms.getEntryManager(SCHEDULE_MODEL_ID); - return await scheduleEntryManager.create({ + const getModel = context.container.resolve(GetModelUseCase); + const createEntry = context.container.resolve(CreateEntryUseCase); + + const modelResult = await getModel.execute(SCHEDULE_MODEL_ID); + const model = modelResult.value; + + const res = await createEntry.execute(model, { id: createScheduleRecordId(values.targetId), ...values, targetModelId: MOCK_TARGET_MODEL_ID }); + + return res.value as CmsEntry; }; it("should fail to handle due to missing schedule entry", async () => { - const handler = new Handler({ - actions: [] - }); + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); try { - const result = await handler.handle({ - cms: context.cms, - security: context.security, - payload: { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() } }); + + if (result.isFail()) { + throw result.error; + } + expect(result).toEqual("SHOULD NOT REACH HERE."); } catch (ex) { - expect(ex).toBeInstanceOf(NotFoundError); expect(ex.message).toEqual( - `Entry by ID "${createEventScheduleRecordId(targetId)}" not found.` + `Entry "${createEventScheduleRecordId(targetId)}" was not found!` ); - expect(ex.code).toEqual("NOT_FOUND"); + expect(ex.code).toEqual("Cms/Entry/NotFound"); } }); it("should fail to find action", async () => { - const handler = new Handler({ - actions: [ - new UnpublishHandlerAction({ - cms: context.cms - }) - ] - }); + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.register(UnpublishRecordAction); + + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); const scheduleEntry = await createScheduleEntry({ targetId, @@ -92,16 +106,17 @@ describe("Handler", () => { expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); try { - const result = await handler.handle({ - cms: context.cms, - security: context.security, - payload: { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() } }); + + if (result.isFail()) { + throw result.error; + } + expect(result).toEqual("SHOULD NOT REACH HERE."); } catch (ex) { expect(ex.message).toEqual( @@ -111,22 +126,21 @@ describe("Handler", () => { }); it("should handle action", async () => { - const handler = new Handler({ - actions: [ - new PublishHandlerAction({ - cms: context.cms - }) - ] - }); + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.register(PublishRecordAction); - const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - const targetEntryManager = await context.cms.getEntryManager(targetModel); + const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - const targetEntry = await targetEntryManager.create({ + const targetEntry = await context.cms.createEntry(targetModel, { id: "target-id", title: "Test Entry" }); + expect(targetEntry.id).toEqual(targetId); const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); @@ -140,7 +154,8 @@ describe("Handler", () => { expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - const scheduler = context.cms.scheduler(targetModel); + const schedulerFactory = testContainer.resolve(SchedulerFactory); + const scheduler = schedulerFactory.useModel(targetModel); const getScheduleEntry = await scheduler.getScheduled(createScheduleRecordId(targetId)); @@ -154,14 +169,10 @@ describe("Handler", () => { type: ScheduleType.publish }); - await handler.handle({ - cms: context.cms, - security: context.security, - payload: { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } + await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() } }); @@ -171,10 +182,9 @@ describe("Handler", () => { expect(afterDeleteScheduledEntry).toBeUndefined(); - const [afterActionTargetEntry] = await context.cms.getPublishedEntriesByIds( - targetEntryManager.model, - [targetId] - ); + const [afterActionTargetEntry] = await context.cms.getPublishedEntriesByIds(targetModel, [ + targetId + ]); expect(afterActionTargetEntry).toMatchObject({ id: targetId, values: { @@ -185,20 +195,21 @@ describe("Handler", () => { }); it("should throw an error while handling action", async () => { - const handler = new Handler({ - actions: [ - { - canHandle: () => true, - async handle(): Promise { - throw new Error("Unknown error."); - } - } - ] + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.registerInstance(RecordAction, { + canHandle: () => true, + async handle(): Promise { + throw new Error("Unknown error."); + } }); - const targetEntryManager = await context.cms.getEntryManager(MOCK_TARGET_MODEL_ID); + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - const targetEntry = await targetEntryManager.create({ + const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const targetEntry = await context.cms.createEntry(targetModel, { id: "target-id", title: "Test Entry" }); @@ -216,16 +227,17 @@ describe("Handler", () => { expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); try { - const result = await handler.handle({ - cms: context.cms, - security: context.security, - payload: { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() } }); + + if (result.isFail()) { + throw result.error; + } + expect(result).toEqual("SHOULD NOT REACH HERE."); } catch (ex) { expect(ex.message).toEqual("Unknown error."); diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts index 19da1023d29..274243385e5 100644 --- a/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts +++ b/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts @@ -1,16 +1,15 @@ import { describe, expect, it } from "vitest"; -import { PublishHandlerAction } from "~/handler/actions/PublishHandlerAction.js"; import { useHandler } from "~tests/mocks/context/useHandler.js"; import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import type { ScheduleContext } from "~/types.js"; import { ScheduleType } from "~/scheduler/types.js"; +import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; +import { RecordAction } from "~/features/ProcessRecords/index.js"; describe("PublishHandlerAction", () => { it("should only handle publish action", async () => { - const action = new PublishHandlerAction({ - cms: {} as ScheduleContext["cms"] - }); + // @ts-expect-error No deps provided; we only test a `canHandle`. + const action = new PublishRecordAction(); expect(action.canHandle({ type: ScheduleType.publish })).toBe(true); expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(false); @@ -26,18 +25,22 @@ describe("PublishHandlerAction", () => { const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - const action = new PublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); try { + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model }); expect(result).toEqual("Should not reach here."); } catch (ex) { - expect(ex.message).toBe(`Entry by ID "target-id#0001" not found.`); + expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); } }); @@ -49,9 +52,12 @@ describe("PublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new PublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -61,6 +67,7 @@ describe("PublishHandlerAction", () => { }); expect(entry.id).toEqual("target-id#0001"); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -83,9 +90,12 @@ describe("PublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new PublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -95,6 +105,7 @@ describe("PublishHandlerAction", () => { }); expect(entry.id).toEqual("target-id#0001"); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -108,6 +119,7 @@ describe("PublishHandlerAction", () => { expect(publishedEntry.id).toBe("target-id#0001"); + // @ts-expect-error We only want to test the base execution. await action.handle({ targetId: "target-id#0001", model @@ -130,9 +142,12 @@ describe("PublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new PublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -142,6 +157,7 @@ describe("PublishHandlerAction", () => { }); expect(entry.id).toEqual("target-id#0001"); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -165,6 +181,7 @@ describe("PublishHandlerAction", () => { } }); + // @ts-expect-error We only want to test the base execution. await action.handle({ targetId: "target-id#0002", model diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts index 8e82382fe1a..c25df5c6dc3 100644 --- a/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts +++ b/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts @@ -1,16 +1,15 @@ -import { UnpublishHandlerAction } from "~/handler/actions/UnpublishHandlerAction.js"; import { useHandler } from "~tests/mocks/context/useHandler.js"; import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import type { ScheduleContext } from "~/types.js"; import { ScheduleType } from "~/scheduler/types.js"; import { describe, expect, it, vi } from "vitest"; +import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; +import { RecordAction } from "~/features/ProcessRecords/index.js"; describe("UnpublishHandlerAction", () => { it("should only handle unpublish action", async () => { - const action = new UnpublishHandlerAction({ - cms: {} as ScheduleContext["cms"] - }); + // @ts-expect-error No deps provided; we only test a `canHandle`. + const action = new UnpublishRecordAction(); expect(action.canHandle({ type: ScheduleType.publish })).toBe(false); expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(true); @@ -26,18 +25,21 @@ describe("UnpublishHandlerAction", () => { const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - const action = new UnpublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); try { + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model }); expect(result).toEqual("Should not reach here."); } catch (ex) { - expect(ex.message).toBe(`Entry by ID "target-id#0001" not found.`); + expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); } }); @@ -49,9 +51,11 @@ describe("UnpublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new UnpublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -63,6 +67,7 @@ describe("UnpublishHandlerAction", () => { console.warn = vi.fn(); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -89,9 +94,11 @@ describe("UnpublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new UnpublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -109,6 +116,7 @@ describe("UnpublishHandlerAction", () => { expect(publishedEntry.id).toBe("target-id#0001"); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -116,6 +124,7 @@ describe("UnpublishHandlerAction", () => { expect(result).toBeUndefined(); + // @ts-expect-error We only want to test the base execution. await action.handle({ targetId: "target-id#0001", model @@ -135,9 +144,11 @@ describe("UnpublishHandlerAction", () => { }); const context = await handler.handler(); - const action = new UnpublishHandlerAction({ - cms: context.cms - }); + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); @@ -173,6 +184,7 @@ describe("UnpublishHandlerAction", () => { expect(publishedOverwriteEntry.id).toBe("target-id#0002"); + // @ts-expect-error We only want to test the base execution. const result = await action.handle({ targetId: "target-id#0001", model @@ -180,6 +192,7 @@ describe("UnpublishHandlerAction", () => { expect(result).toBeUndefined(); + // @ts-expect-error We only want to test the base execution. await action.handle({ targetId: "target-id#0001", model diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts index 21d1a682108..e9a65365540 100644 --- a/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts +++ b/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts @@ -4,8 +4,8 @@ import { createScheduledCmsActionEventHandler } from "~/handler/index.js"; import { registry } from "@webiny/handler-aws/registry.js"; import type { LambdaContext } from "@webiny/handler-aws/types.js"; import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { IWebinyScheduledCmsActionEvent } from "~/handler/Handler.js"; import { createScheduleRecordId } from "~/scheduler/createScheduleRecordId.js"; +import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; describe("Scheduler Event Handler", () => { const lambdaContext = {} as LambdaContext; @@ -41,14 +41,14 @@ describe("Scheduler Event Handler", () => { * We are expecting an error because the context is not set up properly - we dont need it to be set up. */ expect(result).toEqual({ - body: '{"message":"Cannot read properties of undefined (reading \'withoutAuthorization\')"}', + body: '{"message":"No registration found for SchedulerFactory"}', headers: { "access-control-allow-headers": "*", "access-control-allow-methods": "POST", "access-control-allow-origin": "*", "cache-control": "no-store", connection: "keep-alive", - "content-length": "82", + "content-length": "56", "content-type": "text/plain; charset=utf-8", date: expect.toBeDateString() }, diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts index c62d8f53624..79d19c82789 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts @@ -1,10 +1,10 @@ import type { CreateHandlerCoreParams } from "./plugins"; import { createHandlerCore } from "./plugins"; import { createRawEventHandler, createRawHandler } from "@webiny/handler-aws"; -import type { ScheduleContext } from "~/types.js"; import { defaultIdentity } from "./tenancySecurity"; import type { LambdaContext } from "@webiny/handler-aws/types"; import { getElasticsearchClient } from "@webiny/project-utils/testing/elasticsearch"; +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; interface CmsHandlerEvent { path: string; @@ -14,9 +14,7 @@ interface CmsHandlerEvent { }; } -export const useHandler = ( - params: CreateHandlerCoreParams -) => { +export const useHandler = (params: CreateHandlerCoreParams) => { const core = createHandlerCore(params); const plugins = [...core.plugins].concat([ @@ -40,10 +38,9 @@ export const useHandler = ( elasticsearch: elasticsearchClient, handler: (input?: CmsHandlerEvent) => { const payload: CmsHandlerEvent = { - path: "/cms/manage/en-US", + path: "/cms/manage", headers: { "x-webiny-cms-endpoint": "manage", - "x-webiny-cms-locale": "en-US", "x-tenant": "root" }, ...input diff --git a/packages/api-headless-cms-scheduler/package.json b/packages/api-headless-cms-scheduler/package.json index e8a4b8b6c01..16d736904e2 100644 --- a/packages/api-headless-cms-scheduler/package.json +++ b/packages/api-headless-cms-scheduler/package.json @@ -18,6 +18,7 @@ "@webiny/api-headless-cms": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/feature": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/utils": "0.0.0", "zod": "^3.25.76" diff --git a/packages/api-headless-cms-scheduler/src/context.ts b/packages/api-headless-cms-scheduler/src/context.ts index d554368e040..075ef6aa299 100644 --- a/packages/api-headless-cms-scheduler/src/context.ts +++ b/packages/api-headless-cms-scheduler/src/context.ts @@ -6,14 +6,14 @@ import type { import { createSchedulerService } from "~/service/SchedulerService.js"; import { getManifest } from "~/manifest.js"; import { convertException } from "@webiny/utils"; -import type { ScheduleContext } from "~/types.js"; import { createScheduler } from "./scheduler/createScheduler.js"; -import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import type { CmsContext, CmsModel } from "@webiny/api-headless-cms/types/index.js"; import { NotFoundError } from "@webiny/handler-graphql"; import { SCHEDULE_MODEL_ID } from "./constants.js"; import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { attachLifecycleHooks } from "~/hooks/index.js"; +import { SchedulerFactory } from "~/features/Scheduler/index.js"; export interface ICreateHeadlessCmsSchedulerContextParams { getClient(config?: SchedulerClientConfig): Pick; @@ -22,7 +22,7 @@ export interface ICreateHeadlessCmsSchedulerContextParams { export const createHeadlessCmsScheduleContext = ( params: ICreateHeadlessCmsSchedulerContextParams ) => { - return new ContextPlugin(async context => { + return new ContextPlugin(async context => { /** * If the Headless CMS is not ready, it means the system is not fully installed yet. * We do not want to continue because it would break anyway. @@ -63,15 +63,21 @@ export const createHeadlessCmsScheduleContext = ( } attachLifecycleHooks({ - cms: context.cms, - schedulerModel + cms: context.cms }); - context.cms.scheduler = await createScheduler({ + const schedulerFactory = await createScheduler({ cms: context.cms, security: context.security, service, schedulerModel }); + + // Register an adapter + context.container.registerInstance(SchedulerFactory, { + useModel(model) { + return schedulerFactory(model); + } + }); }); }; diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts new file mode 100644 index 00000000000..c4cbde617bb --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts @@ -0,0 +1,158 @@ +import { Result } from "@webiny/feature/api"; +import { WebinyError } from "@webiny/error"; +import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; +import { ProcessRecordsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { RecordAction } from "./abstractions.js"; +import { createIdentifier } from "@webiny/utils/createIdentifier.js"; +import { + AuthenticatedIdentity, + IdentityContext +} from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; +import { CmsModel } from "@webiny/api-headless-cms/types/model.js"; +import { SchedulerFactory } from "~/features/Scheduler/index.js"; + +/** + * RecordProcessorUseCase - Processes scheduled CMS action events + * + * Responsibilities: + * - Fetch the schedule entry from storage + * - Set identity to the user who scheduled the action + * - Find the appropriate action handler + * - Execute the action + * - Clean up schedule entry on success or update with error on failure + */ +class ProcessRecordsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private schedulerFactory: SchedulerFactory.Interface, + private actions: RecordAction.Interface[], + private identityContext: IdentityContext.Interface, + private getModel: GetModelUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private updateEntry: UpdateEntryUseCase.Interface, + private deleteEntry: DeleteEntryUseCase.Interface + ) {} + + public async execute(payload: UseCaseAbstraction.Params): Promise> { + const values = payload[SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]; + + const model = await this.getModelDefinition(SCHEDULE_MODEL_ID); + + const scheduleEntryId = createIdentifier({ + id: values.id, + version: 1 + }); + + /** + * Fetch the schedule entry so we know the model it is targeting. + */ + const scheduleEntryResult = await this.identityContext.withoutAuthorization(() => { + return this.getEntryById.execute(model, scheduleEntryId); + }); + + if (scheduleEntryResult.isFail()) { + return Result.fail(scheduleEntryResult.error); + } + + const scheduleEntry = scheduleEntryResult.value; + + /** + * We want to mock the identity of the user that scheduled this record. + */ + this.identityContext.setIdentity( + new AuthenticatedIdentity({ + id: scheduleEntry.createdBy.id, + type: scheduleEntry.createdBy.type, + displayName: scheduleEntry.createdBy.displayName ?? "" + }) + ); + + const targetModel = await this.getModelDefinition(scheduleEntry.values.targetModelId); + + /** + * We want a formatted schedule record to be used later. + */ + const scheduler = this.schedulerFactory.useModel(targetModel); + const scheduleRecord = await scheduler.getScheduled(scheduleEntryId); + + /** + * Should not happen as we fetched it a few lines up, just in different format. + */ + if (!scheduleRecord) { + return Result.fail( + new WebinyError( + `No schedule record found for ID: ${scheduleEntryId}`, + "SCHEDULE_RECORD_NOT_FOUND", + values + ) + ); + } + + const action = this.actions.find(action => action.canHandle(scheduleRecord)); + if (!action) { + await this.updateEntry.execute(model, scheduleEntryId, { + error: `No action found for schedule record ID.` + }); + + return Result.fail( + new WebinyError( + `No action found for schedule record ID: ${scheduleEntryId}`, + "NO_ACTION_FOUND", + scheduleRecord + ) + ); + } + + try { + await action.handle(scheduleRecord); + } catch (ex) { + console.error(`Error while handling schedule record ID: ${scheduleEntryId}`); + await this.updateEntry.execute(model, scheduleEntryId, { + error: ex.message + }); + return Result.fail(ex); + } + + /** + * Everything is ok. Delete the schedule record. + */ + try { + await this.deleteEntry.execute(model, scheduleEntryId, { + force: true, + permanently: true + }); + } catch { + // Does not matter if it fails. + } + + return Result.ok(); + } + + private async getModelDefinition(modelId: string): Promise { + const modelResult = await this.identityContext.withoutAuthorization(() => { + return this.getModel.execute(modelId); + }); + + if (modelResult.isFail()) { + throw modelResult.error; + } + + return modelResult.value; + } +} + +export const ProcessRecordsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ProcessRecordsUseCaseImpl, + dependencies: [ + SchedulerFactory, + [RecordAction, { multiple: true }], + IdentityContext, + GetModelUseCase, + GetEntryByIdUseCase, + UpdateEntryUseCase, + DeleteEntryUseCase + ] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts new file mode 100644 index 00000000000..23778a32f83 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction, Result } from "@webiny/feature/api"; +import type { IScheduleRecord } from "~/scheduler/types.js"; +import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; + +/** + * RecordAction Abstraction + * + * Handles a specific type of scheduled action (publish, unpublish, etc.) + */ +export interface IRecordAction { + /** + * Determines if this action can handle the given schedule record + */ + canHandle(record: IScheduleRecord): boolean; + + /** + * Processes the schedule record + */ + handle(record: IScheduleRecord): Promise; +} + +export const RecordAction = createAbstraction("RecordAction"); + +export namespace RecordAction { + export type Interface = IRecordAction; +} + +/** + * ProcessRecords Abstraction + * + * Processes scheduled CMS action events by delegating to appropriate actions + */ +export interface IWebinyScheduledCmsActionEventValues { + id: string; // id of the schedule record + scheduleOn: string; +} + +export interface IWebinyScheduledCmsActionEvent { + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: IWebinyScheduledCmsActionEventValues; +} + + +export interface IProcessRecords { + /** + * Processes a scheduled CMS action event + */ + execute(payload: IWebinyScheduledCmsActionEvent): Promise>; +} + +export const ProcessRecordsUseCase = createAbstraction("ProcessRecordsUseCase"); + +export namespace ProcessRecordsUseCase { + export type Interface = IProcessRecords; + export type Params = IWebinyScheduledCmsActionEvent; +} diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts new file mode 100644 index 00000000000..02244be957d --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts @@ -0,0 +1,98 @@ +import { RecordAction as RecordActionAbstraction } from "../abstractions.js"; +import type { IScheduleRecord } from "~/scheduler/types.js"; +import { ScheduleType } from "~/scheduler/types.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds/index.js"; +import { PublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/PublishEntry/index.js"; +import { RepublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/RepublishEntry/abstractions.js"; +import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry/abstractions.js"; + +/** + * PublishRecordAction - Handles publish scheduled actions + * + * Responsibilities: + * - Check if record type is publish + * - Fetch target entry and published entry + * - Handle three scenarios: + * 1. Entry not published -> publish it + * 2. Entry already published (same revision) -> republish it + * 3. Entry has different published revision -> unpublish old, publish new + */ +class PublishRecordActionImpl implements RecordActionAbstraction.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private getPublishedEntriesByIds: GetPublishedEntriesByIdsUseCase.Interface, + private publishEntry: PublishEntryUseCase.Interface, + private republishEntry: RepublishEntryUseCase.Interface, + private unpublishEntry: UnpublishEntryUseCase.Interface + ) {} + + public canHandle(record: Pick): boolean { + return record.type === ScheduleType.publish; + } + + public async handle(record: Pick): Promise { + const { targetId, model } = record; + + const targetEntryResult = await this.getEntryById.execute(model, targetId); + if (targetEntryResult.isFail()) { + throw targetEntryResult.error; + } + + const targetEntry = targetEntryResult.value; + + const publishedTargetEntryResult = await this.getPublishedEntriesByIds.execute(model, [ + targetEntry.id + ]); + if (publishedTargetEntryResult.isFail()) { + throw publishedTargetEntryResult.error; + } + + const [publishedTargetEntry] = publishedTargetEntryResult.value; + + /** + * There are a few scenarios we must handle: + * 1. target entry is not published + * 2. target entry is already published, same revision published + * 3. target entry has a published revision, which is different that the target + */ + + /** + * 1. Has no published revision, so we can publish it. + */ + if (!publishedTargetEntry) { + try { + await this.publishEntry.execute(model, targetEntry.id); + return; + } catch (error) { + console.error(`Failed to publish entry "${targetId}":`, error); + throw error; + } + } else if (publishedTargetEntry.id === targetEntry.id) { + /** + * 2. Target entry is already published. + */ + /** + * Already published, nothing to do. + */ + await this.republishEntry.execute(model, targetEntry.id); + return; + } + /** + * 3. Target entry has a published revision, which is different from the target. + */ + await this.unpublishEntry.execute(model, publishedTargetEntry.id); + await this.publishEntry.execute(model, targetEntry.id); + } +} + +export const PublishRecordAction = RecordActionAbstraction.createImplementation({ + implementation: PublishRecordActionImpl, + dependencies: [ + GetEntryByIdUseCase, + GetPublishedEntriesByIdsUseCase, + PublishEntryUseCase, + RepublishEntryUseCase, + UnpublishEntryUseCase + ] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts new file mode 100644 index 00000000000..4f829895c7f --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts @@ -0,0 +1,80 @@ +import { RecordAction as RecordActionAbstraction } from "../abstractions.js"; +import type { IScheduleRecord } from "~/scheduler/types.js"; +import { ScheduleType } from "~/scheduler/types.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds/index.js"; +import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry/index.js"; + +/** + * UnpublishRecordAction - Handles unpublish scheduled actions + * + * Responsibilities: + * - Check if record type is unpublish + * - Fetch target entry and published entry + * - Handle three scenarios: + * 1. Entry not published -> nothing to do + * 2. Entry published (same revision) -> unpublish it + * 3. Entry has different published revision -> unpublish the published one + */ +class UnpublishRecordActionImpl implements RecordActionAbstraction.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private getPublishedEntriesByIds: GetPublishedEntriesByIdsUseCase.Interface, + private unpublishEntry: UnpublishEntryUseCase.Interface + ) {} + + public canHandle(record: Pick): boolean { + return record.type === ScheduleType.unpublish; + } + + public async handle(record: Pick): Promise { + const { targetId, model } = record; + + /** + * We need to handle the following scenarios: + * 1. Target entry is not published, nothing to do. + * 2. Target entry is published, so we can unpublish it. + * 3. Target entry is published, but it's a different revision than the target. + */ + + const targetEntryResult = await this.getEntryById.execute(model, targetId); + if (targetEntryResult.isFail()) { + throw targetEntryResult.error; + } + + const targetEntry = targetEntryResult.value; + + const publishedTargetEntryResult = await this.getPublishedEntriesByIds.execute(model, [ + targetEntry.id + ]); + if (publishedTargetEntryResult.isFail()) { + throw publishedTargetEntryResult.error; + } + + const [publishedTargetEntry] = publishedTargetEntryResult.value; + + /** + * 1. Target entry is not published, nothing to do. + */ + if (!publishedTargetEntry) { + console.warn(`Entry "${targetId}" is not published, nothing to unpublish.`); + return; + } else if (publishedTargetEntry.id === targetId) { + /** + * 2. Target entry is published, so we can unpublish it. + */ + await this.unpublishEntry.execute(model, targetId); + return; + } + /** + * 3. Target entry is published, but it's a different revision than the target. + * TODO determine if we really want to unpublish an entry which does not match the target ID. + */ + await this.unpublishEntry.execute(model, publishedTargetEntry.id); + } +} + +export const UnpublishRecordAction = RecordActionAbstraction.createImplementation({ + implementation: UnpublishRecordActionImpl, + dependencies: [GetEntryByIdUseCase, GetPublishedEntriesByIdsUseCase, UnpublishEntryUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts new file mode 100644 index 00000000000..08f7cb999c4 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts @@ -0,0 +1,22 @@ +import { createFeature } from "@webiny/feature/api"; +import { ProcessRecordsUseCase } from "./ProcessRecordsUseCase.js"; +import { PublishRecordAction } from "./actions/PublishRecordAction.js"; +import { UnpublishRecordAction } from "./actions/UnpublishRecordAction.js"; + +/** + * ProcessRecordsFeature Feature + * + * Provides functionality for processing scheduled CMS action events. + * Delegates to specific action handlers (publish, unpublish). + */ +export const ProcessRecordsFeature = createFeature({ + name: "ProcessRecordsFeature", + register(container) { + // Register use case + container.register(ProcessRecordsUseCase); + + // Register action handlers + container.register(PublishRecordAction); + container.register(UnpublishRecordAction); + } +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts new file mode 100644 index 00000000000..398cb46f28e --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts @@ -0,0 +1 @@ +export { ProcessRecordsUseCase, RecordAction } from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts new file mode 100644 index 00000000000..66388c49e4d --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts @@ -0,0 +1,13 @@ +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import { createAbstraction } from "@webiny/feature/api"; +import type { IScheduler } from "~/scheduler/types.js"; + +export interface ISchedulerFactory { + useModel(model: CmsModel): IScheduler; +} + +export const SchedulerFactory = createAbstraction("SchedulerFactory"); + +export namespace SchedulerFactory { + export type Interface = ISchedulerFactory; +} diff --git a/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts b/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts new file mode 100644 index 00000000000..5e9b17ca304 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts @@ -0,0 +1 @@ +export { SchedulerFactory } from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/handler/Handler.ts b/packages/api-headless-cms-scheduler/src/handler/Handler.ts deleted file mode 100644 index 14875cfca38..00000000000 --- a/packages/api-headless-cms-scheduler/src/handler/Handler.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { WebinyError } from "@webiny/error"; -import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { IHandlerAction } from "~/handler/types.js"; -import type { IScheduleEntryValues } from "~/scheduler/types.js"; -import type { ScheduleContext } from "~/types.js"; -import { createIdentifier } from "@webiny/utils/createIdentifier.js"; -import { AuthenticatedIdentity } from "@webiny/api-core/features/security/IdentityContext/index.js"; - -export interface IHandlerParams { - actions: IHandlerAction[]; -} - -export interface IHandlerHandleParams { - payload: IWebinyScheduledCmsActionEvent; - cms: Pick; - security: Pick; -} - -export interface IWebinyScheduledCmsActionEventValues { - id: string; // id of the schedule record - scheduleOn: string; -} - -export interface IWebinyScheduledCmsActionEvent { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: IWebinyScheduledCmsActionEventValues; -} - -export class Handler { - private readonly actions: IHandlerAction[]; - - public constructor(params: IHandlerParams) { - this.actions = params.actions; - } - public async handle(params: IHandlerHandleParams): Promise { - const { payload, cms, security } = params; - - const values = payload[SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]; - const scheduleEntryManager = await security.withoutAuthorization(async () => { - return cms.getEntryManager(SCHEDULE_MODEL_ID); - }); - - const scheduleEntryId = createIdentifier({ - id: values.id, - version: 1 - }); - /** - * Just fetch the schedule entry so we know the model it is targeting. - */ - const scheduleEntry = await security.withoutAuthorization(async () => { - return scheduleEntryManager.get(scheduleEntryId); - }); - /** - * We want to mock the identity of the user that scheduled this record. - */ - security.setIdentity( - new AuthenticatedIdentity({ - id: scheduleEntry.createdBy.id, - type: scheduleEntry.createdBy.type, - displayName: scheduleEntry.createdBy.displayName ?? "" - }) - ); - - const targetModel = await cms.getModel(scheduleEntry.values.targetModelId); - /** - * We want a formatted schedule record to be used later. - */ - const scheduler = cms.scheduler(targetModel); - const scheduleRecord = await scheduler.getScheduled(scheduleEntryId); - /** - * Should not happen as we fetched it a few lines up, just in different format. - */ - if (!scheduleRecord) { - throw new WebinyError( - `No schedule record found for ID: ${scheduleEntryId}`, - "SCHEDULE_RECORD_NOT_FOUND", - values - ); - } - - const action = this.actions.find(action => action.canHandle(scheduleRecord)); - if (!action) { - await scheduleEntryManager.update(scheduleEntryId, { - error: `No action found for schedule record ID.` - }); - throw new WebinyError( - `No action found for schedule record ID: ${scheduleEntryId}`, - "NO_ACTION_FOUND", - scheduleRecord - ); - } - - try { - await action.handle(scheduleRecord); - } catch (ex) { - console.error(`Error while handling schedule record ID: ${scheduleEntryId}`); - await scheduleEntryManager.update(scheduleEntryId, { - error: ex.message - }); - throw ex; - } - /** - * Everything is ok. Delete the schedule record. - */ - try { - await scheduleEntryManager.delete(scheduleEntryId, { - force: true, - permanently: true - }); - } catch { - // Does not matter if it fails. - } - } -} diff --git a/packages/api-headless-cms-scheduler/src/handler/actions/PublishHandlerAction.ts b/packages/api-headless-cms-scheduler/src/handler/actions/PublishHandlerAction.ts deleted file mode 100644 index fd67314c824..00000000000 --- a/packages/api-headless-cms-scheduler/src/handler/actions/PublishHandlerAction.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { IHandlerAction } from "~/handler/types.js"; -import type { IScheduleRecord } from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import type { ScheduleContext } from "~/types.js"; - -export type IPublishHandlerActionParamsCms = Pick< - ScheduleContext["cms"], - | "getEntryById" - | "getPublishedEntriesByIds" - | "publishEntry" - | "unpublishEntry" - | "republishEntry" ->; - -export interface IPublishHandlerActionParams { - cms: IPublishHandlerActionParamsCms; -} - -export class PublishHandlerAction implements IHandlerAction { - private readonly cms: IPublishHandlerActionParamsCms; - - public constructor(params: IPublishHandlerActionParams) { - this.cms = params.cms; - } - - public canHandle(record: Pick): boolean { - return record.type === ScheduleType.publish; - } - - public async handle(record: Pick): Promise { - const { targetId, model } = record; - const targetEntry = await this.cms.getEntryById(model, targetId); - const [publishedTargetEntry] = await this.cms.getPublishedEntriesByIds(model, [ - targetEntry.id - ]); - /** - * There are few scenarios we must handle: - * 1. target entry is not published - * 2. target entry is already published, same revision published - * 3. target entry has a published revision, which is different that the target - */ - - /** - * 1. Has no published revision, so we can publish it. - */ - if (!publishedTargetEntry) { - try { - await this.cms.publishEntry(model, targetEntry.id); - return; - } catch (error) { - console.error(`Failed to publish entry "${targetId}":`, error); - throw error; - } - } - /** - * 2. Target entry is already published. - */ - // - else if (publishedTargetEntry.id === targetEntry.id) { - /** - * Already published, nothing to do. - * Maybe republish? - * TODO Do we throw an error here? - */ - await this.cms.republishEntry(model, targetEntry.id); - return; - } - /** - * 3. Target entry has a published revision, which is different from the target. - */ - // - await this.cms.unpublishEntry(model, publishedTargetEntry.id); - await this.cms.publishEntry(model, targetEntry.id); - } -} diff --git a/packages/api-headless-cms-scheduler/src/handler/actions/UnpublishHandlerAction.ts b/packages/api-headless-cms-scheduler/src/handler/actions/UnpublishHandlerAction.ts deleted file mode 100644 index 5180b226508..00000000000 --- a/packages/api-headless-cms-scheduler/src/handler/actions/UnpublishHandlerAction.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { IHandlerAction } from "~/handler/types.js"; -import type { IScheduleRecord } from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import type { ScheduleContext } from "~/types.js"; - -export type IUnpublishHandlerActionParamsCms = Pick< - ScheduleContext["cms"], - "getEntryById" | "getPublishedEntriesByIds" | "unpublishEntry" ->; - -export interface IUnpublishHandlerActionParams { - cms: IUnpublishHandlerActionParamsCms; -} - -export class UnpublishHandlerAction implements IHandlerAction { - private readonly cms: IUnpublishHandlerActionParamsCms; - - public constructor(params: IUnpublishHandlerActionParams) { - this.cms = params.cms; - } - - public canHandle(record: Pick): boolean { - return record.type === ScheduleType.unpublish; - } - public async handle(record: Pick): Promise { - const { targetId, model } = record; - /** - * We need to handle the following scenarios: - * * 1. Target entry is not published, nothing to do. - * * 2. Target entry is published, so we can unpublish it. - * * 3. Target entry is published, but it's a different revision than the target. - */ - const targetEntry = await this.cms.getEntryById(model, targetId); - const [publishedTargetEntry] = await this.cms.getPublishedEntriesByIds(model, [ - targetEntry.id - ]); - - /** - * 1. Target entry is not published, nothing to do. - */ - if (!publishedTargetEntry) { - console.warn(`Entry "${targetId}" is not published, nothing to unpublish.`); - return; - } - /** - * 2. Target entry is published, so we can unpublish it. - */ - // - else if (publishedTargetEntry.id === targetId) { - await this.cms.unpublishEntry(model, targetId); - return; - } - /** - * 3. Target entry is published, but it's a different revision than the target. - * TODO determine if we really want to unpublish an entry which does not match the target ID. - */ - await this.cms.unpublishEntry(model, publishedTargetEntry.id); - } -} diff --git a/packages/api-headless-cms-scheduler/src/handler/index.ts b/packages/api-headless-cms-scheduler/src/handler/index.ts index 66324e54994..8f2f8fcef11 100644 --- a/packages/api-headless-cms-scheduler/src/handler/index.ts +++ b/packages/api-headless-cms-scheduler/src/handler/index.ts @@ -3,11 +3,9 @@ import type { HandlerFactoryParams } from "@webiny/handler-aws/types.js"; import { createSourceHandler } from "@webiny/handler-aws/sourceHandler.js"; import { createEventHandler, createHandler } from "@webiny/handler-aws/raw/index.js"; import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { IWebinyScheduledCmsActionEvent } from "./Handler.js"; -import { Handler } from "./Handler.js"; -import type { ScheduleContext } from "~/types.js"; -import { PublishHandlerAction } from "./actions/PublishHandlerAction.js"; -import { UnpublishHandlerAction } from "./actions/UnpublishHandlerAction.js"; +import { ProcessRecordsFeature } from "~/features/ProcessRecords/feature.js"; +import { ProcessRecordsUseCase } from "~/features/ProcessRecords/index.js"; +import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; export interface HandlerParams extends HandlerFactoryParams { debug?: boolean; @@ -37,29 +35,21 @@ const handler = createSourceHandler { - return createEventHandler({ + return createEventHandler({ canHandle: event => { return canHandle(event); }, handle: async params => { const { payload, context } = params; - const handler = new Handler({ - actions: [ - new PublishHandlerAction({ - cms: context.cms - }), - new UnpublishHandlerAction({ - cms: context.cms - }) - ] - }); + ProcessRecordsFeature.register(context.container); - return handler.handle({ - payload, - cms: context.cms, - security: context.security - }); + const processRecords = context.container.resolve(ProcessRecordsUseCase); + const result = await processRecords.execute(payload); + + if (result.isFail()) { + throw result.error; + } } }); }; diff --git a/packages/api-headless-cms-scheduler/src/handler/types.ts b/packages/api-headless-cms-scheduler/src/handler/types.ts deleted file mode 100644 index ae87a189f77..00000000000 --- a/packages/api-headless-cms-scheduler/src/handler/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { IScheduleRecord } from "~/scheduler/types.js"; - -export interface IHandlerAction { - canHandle(record: IScheduleRecord): boolean; - handle(record: IScheduleRecord): Promise; -} diff --git a/packages/api-headless-cms-scheduler/src/hooks/index.ts b/packages/api-headless-cms-scheduler/src/hooks/index.ts index c6d4b9bc116..805f065c9e1 100644 --- a/packages/api-headless-cms-scheduler/src/hooks/index.ts +++ b/packages/api-headless-cms-scheduler/src/hooks/index.ts @@ -3,22 +3,21 @@ * This is due the possibility that the user publishes/unpublishes/deletes entry which is scheduled. * At that point, there is no need to run the scheduled job as the user already did the action manually. */ -import type { CmsModel } from "@webiny/api-headless-cms/types/model.js"; -import type { CmsEntry } from "@webiny/api-headless-cms/types/types.js"; -import type { ScheduleContext } from "~/types.js"; +import type { CmsContext, CmsEntry, CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import { SCHEDULE_MODEL_ID } from "~/constants.js"; export interface IAttachLifecycleHookParams { - cms: ScheduleContext["cms"]; - schedulerModel: Pick; + cms: CmsContext["cms"]; } export const attachLifecycleHooks = (params: IAttachLifecycleHookParams): void => { - const { cms, schedulerModel } = params; + const { cms } = params; const shouldContinue = (model: Pick): boolean => { - if (model.modelId === schedulerModel.modelId) { + if (model.modelId === SCHEDULE_MODEL_ID) { return false; } + // TODO maybe change with a list of private models which are allowed? else if (model.isPrivate) { return false; diff --git a/packages/api-headless-cms-scheduler/src/index.ts b/packages/api-headless-cms-scheduler/src/index.ts index 6c5d6e53d83..dde3b890b32 100644 --- a/packages/api-headless-cms-scheduler/src/index.ts +++ b/packages/api-headless-cms-scheduler/src/index.ts @@ -9,8 +9,8 @@ export type ICreateHeadlessCmsScheduleParams = ICreateHeadlessCmsSchedulerContex /** * This will register both API and Handler plugins for the Headless CMS Scheduler. - * * Handler plugin will handle the scheduled CMS action event - a lambda call from the EventBridge Scheduler. - * * API plugin will provide the GraphQL API and code for managing the scheduled CMS actions. + * Handler plugin will handle the scheduled CMS action event - a lambda call from the EventBridge Scheduler. + * API plugin will provide the GraphQL API and code for managing the scheduled CMS actions. */ export const createHeadlessCmsScheduler = (params: ICreateHeadlessCmsScheduleParams): Plugin[] => { return [ diff --git a/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts b/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts index 78822681a9e..5912acde25c 100644 --- a/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts +++ b/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts @@ -1,4 +1,3 @@ -import type { CmsScheduleCallable } from "~/types.js"; import type { ISchedulerService } from "~/service/types.js"; import type { CmsContext, CmsModel } from "@webiny/api-headless-cms/types/index.js"; import type { IScheduler } from "./types.js"; @@ -18,12 +17,14 @@ export interface ICreateSchedulerParams { schedulerModel: CmsModel; } +type CmsScheduleCallable = (targetModel: CmsModel) => IScheduler; + export const createScheduler = async ( params: ICreateSchedulerParams ): Promise => { const { cms, security, schedulerModel, service } = params; - return (targetModel): IScheduler => { + return (targetModel: CmsModel): IScheduler => { if (targetModel.isPrivate) { throw new WebinyError( "Cannot create a scheduler for private models.", diff --git a/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts b/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts index 290e00d9298..e549d4177db 100644 --- a/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts +++ b/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts @@ -24,8 +24,8 @@ import { WebinyError } from "@webiny/error"; import { NotFoundError } from "@webiny/handler-graphql"; import { dateInTheFuture } from "~/utils/dateInTheFuture.js"; import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { IWebinyScheduledCmsActionEventValues } from "~/handler/Handler.js"; import { parseIdentifier } from "@webiny/utils"; +import type { IWebinyScheduledCmsActionEventValues } from "~/features/ProcessRecords/abstractions.js"; export interface ISchedulerServiceParams { getClient(config?: SchedulerClientConfig): Pick; diff --git a/packages/api-headless-cms-scheduler/src/types.ts b/packages/api-headless-cms-scheduler/src/types.ts deleted file mode 100644 index cfc7f4e0871..00000000000 --- a/packages/api-headless-cms-scheduler/src/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { CmsContext, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; -import type { IScheduler } from "~/scheduler/types.js"; - -export interface CmsScheduleCallable { - (model: CmsModel): IScheduler; -} - -export interface CmsSchedule { - scheduler: CmsScheduleCallable; -} - -export interface ScheduleContext extends CmsContext { - // - cms: HeadlessCms & CmsSchedule; -} diff --git a/packages/api-headless-cms-scheduler/tsconfig.build.json b/packages/api-headless-cms-scheduler/tsconfig.build.json index 735168ecc81..d50beaf8abd 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.build.json +++ b/packages/api-headless-cms-scheduler/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, { "path": "../api-core/tsconfig.build.json" }, @@ -29,6 +30,10 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/api-headless-cms-scheduler/tsconfig.json b/packages/api-headless-cms-scheduler/tsconfig.json index ee2172861fe..736d659f86a 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.json +++ b/packages/api-headless-cms-scheduler/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../api-headless-cms" }, { "path": "../aws-sdk" }, { "path": "../error" }, + { "path": "../feature" }, { "path": "../handler-graphql" }, { "path": "../utils" }, { "path": "../api-core" }, @@ -29,6 +30,10 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/yarn.lock b/yarn.lock index 04954ed249d..275732894c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14792,6 +14792,7 @@ __metadata: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/error": "npm:0.0.0" + "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" From 6ced0d08c72111d02418bd7ea53b6391d8ce6290 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 17 Nov 2025 22:26:09 +0100 Subject: [PATCH 31/71] wip: migrate headless cms scheduler --- .../src/utils/createIndex.ts | 10 +- .../ARCHITECTURE.md | 497 ++++++++++++++++++ .../src/graphql/index.ts | 33 +- 3 files changed, 523 insertions(+), 17 deletions(-) create mode 100644 packages/api-headless-cms-scheduler/ARCHITECTURE.md diff --git a/packages/api-elasticsearch/src/utils/createIndex.ts b/packages/api-elasticsearch/src/utils/createIndex.ts index 7552a61659e..49532c58cad 100644 --- a/packages/api-elasticsearch/src/utils/createIndex.ts +++ b/packages/api-elasticsearch/src/utils/createIndex.ts @@ -48,13 +48,12 @@ interface IndexCreateParams { index: string; type: string; tenant: string; - locale: string; plugin: ElasticsearchIndexPlugin; onError?: OnError; } const indexCreate = async (params: IndexCreateParams): Promise => { - const { client, index, plugin, tenant, locale, type, onError } = params; + const { client, index, plugin, tenant, type, onError } = params; try { await client.indices.create({ @@ -79,7 +78,6 @@ const indexCreate = async (params: IndexCreateParams): Promise => { data: error.data }, type, - locale, tenant, index, body: plugin.body @@ -93,18 +91,16 @@ interface CreateIndexParams { plugins: PluginsContainer; type: string; tenant: string; - locale: string; index: string; onExists?: OnExists; onError?: OnError; } export const createIndex = async (params: CreateIndexParams): Promise => { - const { plugins, type, locale, onExists } = params; + const { plugins, type, onExists } = params; const plugin = getLastAddedIndexPlugin({ container: plugins, - type, - locale + type }); const exists = await indexExists(params); diff --git a/packages/api-headless-cms-scheduler/ARCHITECTURE.md b/packages/api-headless-cms-scheduler/ARCHITECTURE.md new file mode 100644 index 00000000000..05128fddc48 --- /dev/null +++ b/packages/api-headless-cms-scheduler/ARCHITECTURE.md @@ -0,0 +1,497 @@ +# API Headless CMS Scheduler - Architecture Documentation + +## Overview + +The `@webiny/api-headless-cms-scheduler` package provides scheduling capabilities for Webiny Headless CMS, allowing users to schedule publish and unpublish operations for CMS entries at specific future dates. The package integrates with AWS EventBridge Scheduler to execute scheduled actions via Lambda invocations. + +## Key Entry Points + +### Main Export (`src/index.ts`) + +**`createHeadlessCmsScheduler(params)`** - Primary entry point that registers all necessary plugins: +- Handler plugin for processing scheduled CMS action events +- API plugins for GraphQL operations and context management +- Model definition for storing schedule records + +```typescript +createHeadlessCmsScheduler({ + getClient: (config) => schedulerClient +}) +``` + +## Core Architecture Components + +### 1. Context Layer (`src/context.ts`) + +**Purpose**: Initializes and attaches the scheduler to the CMS context. + +**Key Responsibilities**: +- Validates Headless CMS is ready +- Loads manifest from DynamoDB to get Lambda ARN and IAM role +- Creates `SchedulerService` with AWS credentials +- Attaches `scheduler` callable to `context.cms` +- Registers lifecycle hooks + +**Flow**: `context.ts:22-77` +1. Check if CMS is installed and ready +2. Load scheduler manifest (Lambda ARN, Role ARN) from DynamoDB +3. Get scheduler model (`webinyCmsSchedule`) +4. Attach lifecycle hooks +5. Create and attach scheduler factory to context + +### 2. Scheduler Layer (`src/scheduler/`) + +#### Scheduler Factory (`createScheduler.ts`) + +**Purpose**: Factory function that creates model-specific scheduler instances. + +**Signature**: `(targetModel: CmsModel) => IScheduler` + +**Key Components Created**: +- `ScheduleFetcher` - Retrieves schedule records +- `PublishScheduleAction` - Handles publish scheduling +- `UnpublishScheduleAction` - Handles unpublish scheduling +- `ScheduleExecutor` - Coordinates action execution +- `Scheduler` - Main scheduler interface + +#### Scheduler Class (`Scheduler.ts`) + +**Purpose**: Main scheduler interface implementation using composition pattern. + +**Methods**: +- `schedule(targetId, input)` - Create/update a schedule +- `cancel(id)` - Cancel existing schedule +- `getScheduled(targetId)` - Get schedule for entry +- `listScheduled(params)` - List schedules with filtering + +#### ScheduleExecutor (`ScheduleExecutor.ts`) + +**Purpose**: Executes scheduling operations by delegating to appropriate action classes. + +**Key Methods**: +- `schedule(targetId, input)`: Creates new schedule or reschedules existing + - Generates schedule record ID from target ID + - Checks for existing schedule + - Delegates to appropriate action (Publish/Unpublish) +- `cancel(id)`: Cancels schedule by ID +- `getAction(type)`: Returns appropriate action handler + +#### ScheduleFetcher (`ScheduleFetcher.ts`) + +**Purpose**: Retrieves schedule records from CMS. + +**Key Methods**: +- `getScheduled(targetId)`: Fetches single schedule record +- `listScheduled(params)`: Lists schedules with filtering/pagination + +**Note**: Always filters by target model ID to ensure model isolation. + +#### Schedule Actions + +##### PublishScheduleAction (`actions/PublishScheduleAction.ts`) + +**Purpose**: Handles publish scheduling logic. + +**Key Methods**: + +1. **`schedule(params)`** - Three execution paths: + - **Immediate publish**: Publishes entry immediately, no schedule created + - **Past date**: Updates entry metadata with custom dates, then publishes + - **Future date**: Creates schedule entry in CMS + AWS EventBridge schedule + +2. **`reschedule(original, input)`** - Updates existing schedule: + - If immediate or past date: publishes and cancels schedule + - Otherwise: updates schedule entry and EventBridge schedule + +3. **`cancel(id)`** - Deletes schedule entry and EventBridge schedule + +**Error Handling**: If EventBridge schedule creation fails, deletes the CMS schedule entry (rollback). + +##### UnpublishScheduleAction (`actions/UnpublishScheduleAction.ts`) + +**Purpose**: Similar to PublishScheduleAction but for unpublish operations. + +### 3. Service Layer (`src/service/`) + +#### SchedulerService (`SchedulerService.ts`) + +**Purpose**: Wrapper around AWS EventBridge Scheduler SDK. + +**Key Methods**: +- `create(params)`: Creates EventBridge schedule + - Validates date is in future + - Auto-updates if schedule exists + - Creates one-time schedule with Lambda target +- `update(params)`: Updates existing schedule +- `delete(id)`: Deletes schedule from EventBridge +- `exists(id)`: Checks if schedule exists + +**Schedule Configuration**: +- Expression: `at(YYYY-MM-DDTHH:mm:ss)` format +- Action after completion: DELETE (auto-cleanup) +- Flexible time window: OFF (exact time execution) +- Target: Lambda function with schedule event payload + +**Payload Format**: +```json +{ + "WebinyScheduledCmsAction": { + "id": "schedule-record-id", + "scheduleOn": "2024-01-01T12:00:00.000Z" + } +} +``` + +### 4. Handler Layer (`src/handler/`) + +#### Handler (`Handler.ts`) + +**Purpose**: Processes events from AWS EventBridge Scheduler when schedules execute. + +**Execution Flow**: `Handler.ts:33-105` + +1. Extract schedule ID from event payload +2. Fetch schedule entry from CMS (without authorization) +3. Set identity to the user who scheduled the action +4. Get target model and schedule record +5. Find appropriate handler action (Publish/Unpublish) +6. Execute the action +7. Delete schedule entry on success +8. Update schedule entry with error on failure + +#### Handler Actions + +##### PublishHandlerAction (`actions/PublishHandlerAction.ts`) + +**Purpose**: Executes scheduled publish operations. + +**Logic**: `PublishHandlerAction.ts:30-74` +1. Fetch target entry +2. Check if already published: + - **Not published**: Publish entry + - **Same revision published**: Republish (idempotent) + - **Different revision published**: Unpublish old, publish new + +##### UnpublishHandlerAction (`actions/UnpublishHandlerAction.ts`) + +**Purpose**: Executes scheduled unpublish operations. + +**Logic**: `UnpublishHandlerAction.ts:25-58` +1. Fetch target entry +2. Check publish status: + - **Not published**: Do nothing (log warning) + - **Exact match published**: Unpublish + - **Different revision published**: Unpublish published revision + +#### Event Handler Registration (`handler/index.ts`) + +Registers handler with AWS event system using `createEventHandler`: +- Event identification: Checks for `WebinyScheduledCmsAction` property +- Handler factory: Creates Handler with Publish/Unpublish actions +- Integration: Registered in handler-aws registry + +### 5. GraphQL Layer (`src/graphql/`) + +#### Schema Definition (`graphql/index.ts`) + +**Queries**: +- `getCmsSchedule(modelId, id)`: Get single schedule +- `listCmsSchedules(modelId, where, sort, limit, after)`: List schedules + +**Mutations**: +- `createCmsSchedule(modelId, id, input)`: Create schedule +- `updateCmsSchedule(modelId, id, input)`: Update schedule +- `cancelCmsSchedule(modelId, id)`: Cancel schedule + +**Input Validation**: Uses Zod schemas (`graphql/schema.ts`) for runtime validation + +### 6. Data Model Layer (`src/scheduler/model.ts`) + +**Model ID**: `webinyCmsSchedule` + +**Fields**: +- `targetId`: ID of the entry to schedule (with version) +- `targetModelId`: Model ID of target entry +- `scheduledBy`: Identity object (id, displayName, type) +- `scheduledOn`: DateTime when action should execute +- `dateOn`: DateTime for custom date metadata (currently unused) +- `type`: "publish" or "unpublish" +- `title`: Title of target entry +- `error`: Error message if execution failed + +**Type**: Private model (not exposed to public GraphQL API) + +### 7. Lifecycle Hooks (`src/hooks/index.ts`) + +**Purpose**: Auto-cleanup when entries are manually published/unpublished/deleted. + +**Hooks Registered**: +- `onEntryAfterPublish`: Cancel publish schedule +- `onEntryAfterUnpublish`: Cancel unpublish schedule +- `onEntryAfterDelete`: Cancel any schedule + +**Reasoning**: If user performs action manually, scheduled action becomes obsolete. + +### 8. Manifest System (`src/manifest.ts`) + +**Purpose**: Loads scheduler configuration from DynamoDB Service Discovery. + +**Schema**: +```typescript +{ + scheduler: { + lambdaArn: string, // ARN of handler Lambda + roleArn: string // IAM role for EventBridge + } +} +``` + +**Error Handling**: Returns error object if manifest missing or invalid. + +## Data Flow Diagrams + +### Creating a Schedule + +``` +GraphQL Mutation (createCmsSchedule) + ↓ +Validation (Zod schema) + ↓ +Get target model + ↓ +Get scheduler for model (context.cms.scheduler(model)) + ↓ +scheduler.schedule(targetId, input) + ↓ +ScheduleExecutor.schedule() + ↓ +ScheduleAction.schedule() (Publish/Unpublish) + ↓ +├─ Immediate? → Publish/Unpublish entry directly +├─ Past date? → Update metadata + Publish/Unpublish +└─ Future date? + ↓ + Create CMS entry (webinyCmsSchedule) + ↓ + SchedulerService.create() + ↓ + AWS EventBridge Scheduler (creates schedule) +``` + +### Executing a Schedule + +``` +AWS EventBridge Scheduler (triggers at scheduled time) + ↓ +Lambda invocation with payload + ↓ +Handler.handle() + ↓ +Fetch schedule entry (bypass authorization) + ↓ +Set identity to scheduler + ↓ +Get target model & entry + ↓ +Find handler action (Publish/Unpublish) + ↓ +HandlerAction.handle() + ↓ +├─ PublishHandlerAction: Check if published → Publish/Republish +└─ UnpublishHandlerAction: Check if published → Unpublish + ↓ +Delete schedule entry (cleanup) +``` + +### Canceling a Schedule + +``` +GraphQL Mutation (cancelCmsSchedule) + ↓ +scheduler.cancel(id) + ↓ +ScheduleExecutor.cancel() + ↓ +Fetch existing schedule + ↓ +ScheduleAction.cancel() + ↓ +Delete CMS entry (webinyCmsSchedule) + ↓ +SchedulerService.delete() + ↓ +AWS EventBridge Scheduler (delete schedule) +``` + +## Important Constants (`src/constants.ts`) + +- `SCHEDULE_MODEL_ID`: "webinyCmsSchedule" - CMS model for schedules +- `SCHEDULE_ID_PREFIX`: "wby-schedule-" - Prefix for schedule IDs +- `SCHEDULE_MIN_FUTURE_SECONDS`: 65 - Minimum seconds in future for scheduling +- `SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER`: "WebinyScheduledCmsAction" - Event type identifier + +## Key Design Patterns + +### 1. Factory Pattern +- `createScheduler()` creates model-specific scheduler instances +- Each model gets isolated scheduler with its own actions + +### 2. Strategy Pattern +- `IScheduleAction` interface with Publish/Unpublish implementations +- `IHandlerAction` interface with Publish/Unpublish handlers +- Actions selected based on schedule type + +### 3. Composition Pattern +- `Scheduler` composes `ScheduleFetcher` and `ScheduleExecutor` +- `ScheduleExecutor` composes multiple `ScheduleAction` instances + +### 4. Repository Pattern +- `ScheduleFetcher` abstracts data retrieval +- `SchedulerService` abstracts AWS EventBridge operations + +## Security Considerations + +1. **Authorization Bypass**: Handler runs `withoutAuthorization()` to fetch schedule entries +2. **Identity Impersonation**: Handler sets identity to original scheduler for proper permissions +3. **Model Isolation**: Schedulers only operate on their assigned model +4. **Private Model**: Schedule model not exposed to public API + +## Error Handling Strategy + +1. **Schedule Creation Failure**: Rollback CMS entry if EventBridge fails +2. **Execution Failure**: Update schedule entry with error message +3. **Missing Schedules**: Return null for not-found schedules +4. **Manifest Errors**: Log and skip scheduler attachment entirely + +## Dependencies + +### Runtime +- `@webiny/api-headless-cms`: CMS core functionality +- `@webiny/aws-sdk`: AWS EventBridge Scheduler client +- `@webiny/handler-graphql`: GraphQL resolvers +- `zod`: Schema validation + +### Infrastructure +- AWS EventBridge Scheduler +- AWS Lambda (for handler) +- IAM Role (for EventBridge to invoke Lambda) +- DynamoDB (for manifest storage) + +## Extension Points + +### Adding New Schedule Types + +1. Create new `ScheduleAction` implementing `IScheduleAction` +2. Create new `HandlerAction` implementing `IHandlerAction` +3. Add to actions array in `createScheduler()` and `createScheduledCmsActionEventHandler()` +4. Update `ScheduleType` enum in `types.ts` +5. Update GraphQL schema + +### Custom Schedule Validation + +Override or extend Zod schemas in `graphql/schema.ts` + +### Additional Lifecycle Hooks + +Add hooks in `hooks/index.ts` using CMS lifecycle events + +## Testing Strategy + +Tests are organized by layer: +- `/scheduler/` - Scheduler, Executor, Fetcher, Actions +- `/handler/` - Handler and Handler Actions +- `/service/` - SchedulerService (uses aws-sdk-client-mock) +- `/graphql/` - GraphQL schema validation + +## File Structure Summary + +``` +src/ +├── index.ts # Main entry point +├── context.ts # Context plugin setup +├── types.ts # Type definitions for context +├── constants.ts # Global constants +├── manifest.ts # Manifest loader +├── graphql/ +│ ├── index.ts # GraphQL plugin +│ └── schema.ts # Zod validation schemas +├── scheduler/ +│ ├── createScheduler.ts # Scheduler factory +│ ├── Scheduler.ts # Main scheduler class +│ ├── ScheduleExecutor.ts # Execution coordinator +│ ├── ScheduleFetcher.ts # Data retrieval +│ ├── ScheduleRecord.ts # Record transformations +│ ├── model.ts # CMS model definition +│ ├── types.ts # Scheduler types +│ ├── dates.ts # Date utilities +│ ├── createScheduleRecordId.ts # ID generation +│ └── actions/ +│ ├── PublishScheduleAction.ts +│ └── UnpublishScheduleAction.ts +├── handler/ +│ ├── index.ts # Event handler registration +│ ├── Handler.ts # Main handler class +│ ├── types.ts # Handler types +│ └── actions/ +│ ├── PublishHandlerAction.ts +│ └── UnpublishHandlerAction.ts +├── service/ +│ ├── SchedulerService.ts # AWS EventBridge wrapper +│ └── types.ts # Service types +├── hooks/ +│ └── index.ts # Lifecycle hooks +└── utils/ + └── dateInTheFuture.ts # Date validation +``` + +## Common Use Cases + +### 1. Schedule Entry Publication +User wants to publish a blog post at a future date: +- User creates draft entry +- Calls `createCmsSchedule` with future date and type="publish" +- System creates schedule in CMS and EventBridge +- At scheduled time, entry is published automatically + +### 2. Scheduled Unpublish +User wants to unpublish content after campaign ends: +- User has published entry +- Calls `createCmsSchedule` with future date and type="unpublish" +- At scheduled time, entry is unpublished + +### 3. Reschedule Operation +User wants to change publication date: +- Calls `updateCmsSchedule` with new date +- System updates both CMS entry and EventBridge schedule + +### 4. Manual Override +User manually publishes scheduled entry: +- `onEntryAfterPublish` hook triggers +- System cancels schedule automatically +- EventBridge schedule is deleted + +## Performance Considerations + +1. **Pagination**: List operations support cursor-based pagination +2. **Model Filtering**: Queries filtered by model ID at database level +3. **Lazy Loading**: Scheduler created per-model on-demand +4. **Auto-cleanup**: EventBridge schedules auto-delete after execution + +## Troubleshooting + +### Schedule Not Executing +1. Check schedule entry exists in CMS (`webinyCmsSchedule`) +2. Verify EventBridge schedule exists (use AWS console or `SchedulerService.exists()`) +3. Check Lambda execution logs for handler errors +4. Verify IAM role has Lambda invoke permissions + +### Schedule Entry Has Error Field +Check `error` field in schedule entry for execution failure details + +### Scheduler Not Available +1. Check manifest loaded successfully (logs on startup) +2. Verify `webinyCmsSchedule` model exists +3. Ensure CMS is fully installed + +### Date Validation Errors +Verify date is at least 65 seconds in future (`SCHEDULE_MIN_FUTURE_SECONDS`) \ No newline at end of file diff --git a/packages/api-headless-cms-scheduler/src/graphql/index.ts b/packages/api-headless-cms-scheduler/src/graphql/index.ts index cae6e94afaa..553a8332421 100644 --- a/packages/api-headless-cms-scheduler/src/graphql/index.ts +++ b/packages/api-headless-cms-scheduler/src/graphql/index.ts @@ -1,7 +1,6 @@ import { CmsGraphQLSchemaPlugin } from "@webiny/api-headless-cms/plugins/index.js"; -import type { ScheduleContext } from "~/types.js"; import { ErrorResponse, ListErrorResponse, ListResponse, Response } from "@webiny/handler-graphql"; -import type { CmsEntryMeta } from "@webiny/api-headless-cms/types/index.js"; +import type { CmsContext, CmsEntryMeta } from "@webiny/api-headless-cms/types/index.js"; import { cancelScheduleSchema, createScheduleSchema, @@ -10,6 +9,7 @@ import { updateScheduleSchema } from "~/graphql/schema.js"; import { createZodError } from "@webiny/utils"; +import { SchedulerFactory } from "~/features/Scheduler/index.js"; const resolve = async (cb: () => Promise) => { try { @@ -37,12 +37,17 @@ const resolveList = async (cb: () => Promise) => { }; export const createSchedulerGraphQL = () => { - return new CmsGraphQLSchemaPlugin({ + return new CmsGraphQLSchemaPlugin({ /** - * Make sure scheduler is available. No point in adding GraphQL if scheduler is unavailable for any reason. + * Make sure SchedulerFactory is available. No point in adding GraphQL if scheduler is unavailable for any reason. */ isApplicable: context => { - return !!context.cms?.scheduler; + try { + context.container.resolve(SchedulerFactory); + return true; + } catch { + return false; + } }, typeDefs: /* GraphQL */ ` enum CmsScheduleRecordType { @@ -150,8 +155,10 @@ export const createSchedulerGraphQL = () => { if (validated.error) { throw createZodError(validated.error); } + + const schedulerFactory = context.container.resolve(SchedulerFactory); const model = await context.cms.getModel(validated.data.modelId); - const scheduler = context.cms.scheduler(model); + const scheduler = schedulerFactory.useModel(model); return scheduler.getScheduled(validated.data.id); }); @@ -162,8 +169,9 @@ export const createSchedulerGraphQL = () => { if (validated.error) { throw createZodError(validated.error); } + const schedulerFactory = context.container.resolve(SchedulerFactory); const model = await context.cms.getModel(validated.data.modelId); - const scheduler = context.cms.scheduler(model); + const scheduler = schedulerFactory.useModel(model); return scheduler.listScheduled({ where: validated.data.where || {}, @@ -182,8 +190,9 @@ export const createSchedulerGraphQL = () => { throw createZodError(validated.error); } + const schedulerFactory = context.container.resolve(SchedulerFactory); const model = await context.cms.getModel(validated.data.modelId); - const scheduler = context.cms.scheduler(model); + const scheduler = schedulerFactory.useModel(model); return await scheduler.schedule(validated.data.id, validated.data.input); }); @@ -194,8 +203,10 @@ export const createSchedulerGraphQL = () => { if (validated.error) { throw createZodError(validated.error); } + + const schedulerFactory = context.container.resolve(SchedulerFactory); const model = await context.cms.getModel(validated.data.modelId); - const scheduler = context.cms.scheduler(model); + const scheduler = schedulerFactory.useModel(model); return scheduler.schedule(validated.data.id, validated.data.input); }); @@ -206,8 +217,10 @@ export const createSchedulerGraphQL = () => { if (validated.error) { throw createZodError(validated.error); } + + const schedulerFactory = context.container.resolve(SchedulerFactory); const model = await context.cms.getModel(validated.data.modelId); - const scheduler = context.cms.scheduler(model); + const scheduler = schedulerFactory.useModel(model); await scheduler.cancel(validated.data.id); return true; From 0329a2cc892f07c688dbe6625a1e5a28ed92d1dc Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 00:17:22 +0100 Subject: [PATCH 32/71] wip: migrate headless cms scheduler --- .../api-headless-cms-scheduler/SCHEDULER.md | 889 ++++++++++++++++++ 1 file changed, 889 insertions(+) create mode 100644 packages/api-headless-cms-scheduler/SCHEDULER.md diff --git a/packages/api-headless-cms-scheduler/SCHEDULER.md b/packages/api-headless-cms-scheduler/SCHEDULER.md new file mode 100644 index 00000000000..4eb28fc4a54 --- /dev/null +++ b/packages/api-headless-cms-scheduler/SCHEDULER.md @@ -0,0 +1,889 @@ +# Scheduler Migration Plan + +## Overview + +This document outlines the migration plan for refactoring the scheduler component (`src/scheduler/`) to utilize the DI container, abstractions, and the features architecture pattern. The goal is to align the scheduler implementation with the same patterns used in the ProcessRecords feature. + +## Current State Analysis + +### Current Architecture + +**Factory Pattern (`createScheduler.ts`)** +- Factory function creates model-specific scheduler instances +- Manually instantiates actions: `new PublishScheduleAction()`, `new UnpublishScheduleAction()` +- No DI container usage - tight coupling +- Comment exists: `// TODO: inject actions!!!` (line 51) + +**Core Components** +1. **Scheduler** - Main interface (composition pattern) +2. **ScheduleExecutor** - Coordinates action execution +3. **ScheduleFetcher** - Retrieves schedule records +4. **Schedule Actions** - PublishScheduleAction, UnpublishScheduleAction (implement IScheduleAction) +5. **SchedulerService** - AWS EventBridge wrapper + +### Current Dependency Flow +``` +createScheduler (factory) + ↓ +Manually creates: + - ScheduleFetcher + - PublishScheduleAction (with many dependencies) + - UnpublishScheduleAction (with many dependencies) + - ScheduleExecutor (with actions array) + - Scheduler (with fetcher + executor) +``` + +## Problems with Current Implementation + +1. **Tight Coupling**: Actions are manually instantiated with all dependencies +2. **No Abstraction Layer**: No proper abstraction for Scheduler, Executor, or Fetcher +3. **Testing Difficulty**: Cannot easily mock dependencies +4. **Extensibility Issues**: Adding new schedule types requires modifying factory +5. **Inconsistent Patterns**: Handler layer uses DI but scheduler doesn't +6. **Repeated Dependency Injection**: Each action receives same CMS dependencies manually + +## Proposed Architecture + +### Feature Structure + +``` +src/features/Scheduler/ +├── abstractions.ts # All abstractions +├── index.ts # Exports +├── feature.ts # Feature registration +├── ScheduleExecutorUseCase.ts # Executor with DI +├── ScheduleFetcherUseCase.ts # Fetcher with DI +├── ScheduleRecordUseCase.ts # Command: Schedule a record +├── CancelScheduledRecordUseCase.ts # Command: Cancel a schedule +├── GetScheduledRecordUseCase.ts # Query: Get single schedule +├── ListScheduledRecordsUseCase.ts # Query: List schedules +├── ValidateNotPrivateModelDecorator.ts # Validation decorator +├── EventBridgeSchedulerService.ts # AWS EventBridge wrapper +└── actions/ + ├── PublishScheduleAction.ts # Publish action with DI + └── UnpublishScheduleAction.ts # Unpublish action with DI +``` + +## Migration Steps + +### Phase 1: Create Abstractions + +**File**: `features/Scheduler/abstractions.ts` + +Create the following abstractions: + +1. **ScheduleAction** - Abstraction for schedule actions (already exists as interface `IScheduleAction`) + ```typescript + export const ScheduleAction = createAbstraction("ScheduleAction"); + ``` + +2. **ScheduleFetcher** - Abstraction for fetching schedule records + ```typescript + export interface IScheduleFetcherUseCase { + getScheduled(targetId: string): Promise; + listScheduled(params: ISchedulerListParams): Promise; + } + export const ScheduleFetcher = createAbstraction("ScheduleFetcher"); + ``` + +3. **ScheduleExecutor** - Abstraction for executing schedules + ```typescript + export interface IScheduleExecutorUseCase { + schedule(targetId: string, input: ISchedulerInput): Promise; + cancel(id: string): Promise; + } + export const ScheduleExecutor = createAbstraction("ScheduleExecutor"); + ``` + +4. **Individual Use Cases** - One responsibility per use case + + **ScheduleRecord** - Create or update a schedule + ```typescript + export interface IScheduleRecordUseCase { + execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise; + } + export const ScheduleRecordUseCase = createAbstraction("ScheduleRecordUseCase"); + ``` + + **CancelScheduledRecord** - Cancel an existing schedule + ```typescript + export interface ICancelScheduledRecordUseCase { + execute(targetModel: CmsModel, id: string): Promise; + } + export const CancelScheduledRecordUseCase = createAbstraction("CancelScheduledRecordUseCase"); + ``` + + **GetScheduledRecord** - Get a single schedule + ```typescript + export interface IGetScheduledRecordUseCase { + execute(targetModel: CmsModel, id: string): Promise; + } + export const GetScheduledRecordUseCase = createAbstraction("GetScheduledRecordUseCase"); + ``` + + **ListScheduledRecords** - List schedules with filtering + ```typescript + export interface IListScheduledRecordsUseCase { + execute(targetModel: CmsModel, params: ISchedulerListParams): Promise; + } + export const ListScheduledRecordsUseCase = createAbstraction("ListScheduledRecordsUseCase"); + ``` + + **Note**: `targetModel` is passed as a method parameter, not injected via constructor. This is because the model varies per request while dependencies (executor, fetcher) remain constant. + +5. **SchedulerService** - AWS EventBridge abstraction (from service layer) + ```typescript + export const SchedulerService = createAbstraction("SchedulerService"); + ``` + + +### Phase 2: Refactor Actions to Use DI + +**Files**: +- `features/Scheduler/actions/PublishScheduleAction.ts` +- `features/Scheduler/actions/UnpublishScheduleAction.ts` + +**Current Dependencies** (manually injected): +- `service: ISchedulerService` +- `cms: PublishScheduleActionCms` +- `targetModel: CmsModel` ← Now passed as method parameter +- `schedulerModel: CmsModel` ← Injected as instance +- `getIdentity: () => CmsIdentity` ← Injected as factory +- `fetcher: IScheduleFetcher` + +**Proposed Approach**: + +```typescript +class PublishScheduleActionImpl implements ScheduleAction.Interface { + constructor( + private eventBridgeService: EventBridgeSchedulerService.Interface, + private schedulerModel: CmsModel, // Registered as instance + private getIdentity: () => CmsIdentity, // Registered as factory + // TODO: Create CMS use case abstractions for: + // - GetEntryByIdUseCase + // - PublishEntryUseCase + // - CreateEntryUseCase + // - UpdateEntryUseCase + // - DeleteEntryUseCase + ) {} + + canHandle(input: ISchedulerInput): boolean { + return input.type === ScheduleType.publish; + } + + async schedule(targetModel: CmsModel, params: IScheduleActionScheduleParams): Promise { + // targetModel passed as parameter + const { targetId, input, scheduleRecordId } = params; + + // TODO: Use GetEntryByIdUseCase with targetModel + const targetEntry = await this.getEntryById.execute(targetModel, targetId); + + // ... rest of implementation + } + + // reschedule and cancel also receive targetModel as parameter +} + +export const PublishScheduleAction = ScheduleAction.createImplementation({ + implementation: PublishScheduleActionImpl, + dependencies: [ + EventBridgeSchedulerService, + SchedulerModel, // Instance + IdentityProvider, // Factory + // TODO: Add CMS use case abstractions + ] +}); +``` + +**Key Change**: `targetModel` is now a method parameter, not a constructor dependency. + +### Phase 3: Create Use Case Implementations + +#### 3.1 ScheduleFetcherUseCase + +**File**: `features/Scheduler/ScheduleFetcherUseCase.ts` + +**Current Dependencies**: +- `cms: Pick` +- `targetModel: CmsModel` ← Now passed as method parameter +- `schedulerModel: CmsModel` ← Injected as instance + +**Proposed**: +```typescript +class ScheduleFetcherUseCaseImpl implements ScheduleFetcher.Interface { + constructor( + private schedulerModel: CmsModel, // Registered as instance + // TODO: Create abstractions for: + // - GetEntryByIdUseCase + // - ListLatestEntriesUseCase + ) {} + + async getScheduled(targetModel: CmsModel, targetId: string): Promise { + const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); + + // TODO: Use GetEntryByIdUseCase + const entry = await this.getEntryById.execute(this.schedulerModel, scheduleRecordId); + + // Filter by targetModel + if (entry.values.targetModelId !== targetModel.modelId) { + return null; + } + + return transformScheduleEntry(targetModel, entry); + } + + async listScheduled(targetModel: CmsModel, params: ISchedulerListParams): Promise { + // TODO: Use ListLatestEntriesUseCase + const [data, meta] = await this.listLatestEntries.execute(this.schedulerModel, { + ...params, + where: { + ...params.where, + targetModelId: targetModel.modelId + } + }); + + return { + data: data.map(item => transformScheduleEntry(targetModel, item)), + meta + }; + } +} + +export const ScheduleFetcherUseCase = ScheduleFetcher.createImplementation({ + implementation: ScheduleFetcherUseCaseImpl, + dependencies: [ + SchedulerModel, // Instance + // TODO: Add CMS use case abstractions + ] +}); +``` + +**Note**: `targetModel` is passed as method parameter, `schedulerModel` is injected once. + +#### 3.2 ScheduleExecutorUseCase + +**File**: `features/Scheduler/ScheduleExecutorUseCase.ts` + +**Current Dependencies**: +- `actions: IScheduleAction[]` +- `fetcher: IScheduleFetcher` + +**Proposed**: +```typescript +class ScheduleExecutorUseCaseImpl implements ScheduleExecutor.Interface { + constructor( + private actions: ScheduleAction.Interface[], + private fetcher: ScheduleFetcher.Interface + ) {} + + async schedule(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { + const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); + + // Pass targetModel to fetcher + const original = await this.fetcher.getScheduled(targetModel, targetId); + + const action = this.getAction(input.type); + + if (original) { + // Pass targetModel to action + return action.reschedule(targetModel, original, input); + } + + // Pass targetModel to action + return action.schedule(targetModel, { + scheduleRecordId, + targetId, + input + }); + } + + async cancel(targetModel: CmsModel, initialId: string): Promise { + const id = createScheduleRecordIdWithVersion(initialId); + + // Pass targetModel to fetcher + const original = await this.fetcher.getScheduled(targetModel, id); + + if (!original) { + throw new WebinyError(`No scheduled record found for ID "${id}".`, "SCHEDULED_RECORD_NOT_FOUND"); + } + + const action = this.getAction(original.type); + + // Pass targetModel to action + await action.cancel(targetModel, original.id); + + return original; + } + + private getAction(type: ScheduleType): ScheduleAction.Interface { + const action = this.actions.find(action => action.canHandle({ type })); + if (!action) { + throw new WebinyError(`No action found for input type "${type}".`, "NO_ACTION_FOUND"); + } + return action; + } +} + +export const ScheduleExecutorUseCase = ScheduleExecutor.createImplementation({ + implementation: ScheduleExecutorUseCaseImpl, + dependencies: [ + [ScheduleAction, { multiple: true }], + ScheduleFetcher + ] +}); +``` + +**Key Change**: Executor receives `targetModel` and passes it down to fetcher and actions. + +#### 3.3 Individual Use Case Implementations + +**ScheduleRecordUseCase** - Delegates to ScheduleExecutor +```typescript +class ScheduleRecordUseCaseImpl implements ScheduleRecordUseCase.Interface { + constructor(private executor: ScheduleExecutor.Interface) {} + + async execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { + return this.executor.schedule(targetModel, targetId, input); + } +} + +export const ScheduleRecordUseCaseImplementation = ScheduleRecordUseCase.createImplementation({ + implementation: ScheduleRecordUseCaseImpl, + dependencies: [ScheduleExecutor] +}); +``` + +**CancelScheduledRecordUseCase** - Delegates to ScheduleExecutor +```typescript +class CancelScheduledRecordUseCaseImpl implements CancelScheduledRecordUseCase.Interface { + constructor(private executor: ScheduleExecutor.Interface) {} + + async execute(targetModel: CmsModel, id: string): Promise { + return this.executor.cancel(targetModel, id); + } +} + +export const CancelScheduledRecordUseCaseImplementation = CancelScheduledRecordUseCase.createImplementation({ + implementation: CancelScheduledRecordUseCaseImpl, + dependencies: [ScheduleExecutor] +}); +``` + +**GetScheduledRecordUseCase** - Delegates to ScheduleFetcher +```typescript +class GetScheduledRecordUseCaseImpl implements GetScheduledRecordUseCase.Interface { + constructor(private fetcher: ScheduleFetcher.Interface) {} + + async execute(targetModel: CmsModel, id: string): Promise { + return this.fetcher.getScheduled(targetModel, id); + } +} + +export const GetScheduledRecordUseCaseImplementation = GetScheduledRecordUseCase.createImplementation({ + implementation: GetScheduledRecordUseCaseImpl, + dependencies: [ScheduleFetcher] +}); +``` + +**ListScheduledRecordsUseCase** - Delegates to ScheduleFetcher +```typescript +class ListScheduledRecordsUseCaseImpl implements ListScheduledRecordsUseCase.Interface { + constructor(private fetcher: ScheduleFetcher.Interface) {} + + async execute(targetModel: CmsModel, params: ISchedulerListParams): Promise { + return this.fetcher.listScheduled(targetModel, params); + } +} + +export const ListScheduledRecordsUseCaseImplementation = ListScheduledRecordsUseCase.createImplementation({ + implementation: ListScheduledRecordsUseCaseImpl, + dependencies: [ScheduleFetcher] +}); +``` + +**Key Change**: All use cases pass `targetModel` through to their dependencies. + + +### Phase 4: Add Model Validation Decorator + +**File**: `features/Scheduler/ValidateNotPrivateModelDecorator.ts` + +**Purpose**: Validate that `targetModel` is not private before executing any schedule operation. + +```typescript +class ValidateNotPrivateModelDecorator implements ScheduleRecordUseCase.Interface { + constructor(private decoratee: ScheduleRecordUseCase.Interface) {} + + async execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { + if (targetModel.isPrivate) { + throw new WebinyError( + "Cannot schedule operations on private models.", + "PRIVATE_MODEL_ERROR", + { modelId: targetModel.modelId } + ); + } + + return this.decoratee.execute(targetModel, targetId, input); + } +} + +export const ValidateNotPrivateModel = ScheduleRecordUseCase.createDecorator({ + decorator: ValidateNotPrivateModelDecorator, + dependencies: [] +}); +``` + +**Registration**: +```typescript +// In feature.ts +container.registerDecorator(ValidateNotPrivateModel); +``` + +**Note**: This decorator only needs to be registered for command use cases (ScheduleRecordUseCase, CancelScheduledRecordUseCase). Query use cases (Get, List) don't need validation since they're read-only. + +### Phase 5: Create Feature Registration + +**File**: `features/Scheduler/feature.ts` + +```typescript +export const SchedulerFeature = createFeature({ + name: "Scheduler", + register(container) { + // Register AWS EventBridge service layer + // TODO: SchedulerService needs to be refactored to use abstraction + // container.register(EventBridgeSchedulerServiceImpl).inSingletonScope(); + + // Register infrastructure components + container.register(ScheduleFetcherUseCase); + container.register(ScheduleExecutorUseCase); + + // Register individual use cases + container.register(ScheduleRecordUseCaseImplementation); + container.register(CancelScheduledRecordUseCaseImplementation); + container.register(GetScheduledRecordUseCaseImplementation); + container.register(ListScheduledRecordsUseCaseImplementation); + + // Register action implementations + container.register(PublishScheduleAction); + container.register(UnpublishScheduleAction); + + // Register validation decorator for commands + container.registerDecorator(ValidateNotPrivateModel); + } +}); +``` + +### Phase 6: Refactor SchedulerService + +**File**: `features/Scheduler/SchedulerServiceImpl.ts` + +**Current State**: Class with constructor injection of client and config + +**Proposed**: +```typescript +class SchedulerServiceImpl implements SchedulerService.Interface { + constructor( + private getClient: (config?: SchedulerClientConfig) => Pick, + private config: ISchedulerServiceConfig + ) {} + + // ... existing methods +} + +export const SchedulerServiceImplementation = SchedulerService.createImplementation({ + implementation: SchedulerServiceImpl, + dependencies: [ + // TODO: How to inject AWS client factory? + // TODO: How to inject config (from manifest)? + ] +}); +``` + +**Challenge**: `getClient` and `config` come from external sources (manifest, AWS SDK). + +**Solution**: Register as instances in context setup: +```typescript +// In context.ts after loading manifest +container.registerInstance(SchedulerServiceConfig, { + lambdaArn: manifest.scheduler.lambdaArn, + roleArn: manifest.scheduler.roleArn +}); +container.registerFactory(SchedulerServiceClientFactory, () => getClient); +``` + +### Phase 7: Update Context Integration + +**File**: `src/context.ts` + +**Current State**: +```typescript +const scheduler = await createScheduler({ + security, + cms, + service: schedulerService, + schedulerModel +}); +context.cms.scheduler = scheduler; +``` + +**Proposed**: +```typescript +// Register feature +SchedulerFeature.register(context.container); + +// Register manifest-based instances +container.registerInstance(SchedulerServiceConfig, manifestConfig); +container.registerInstance(SchedulerModel, schedulerModel); + +// Register identity provider as factory +container.registerFactory(IdentityProvider, () => security.getIdentity()); + +// No context.cms.scheduler - GraphQL resolvers use container directly +``` + +**Note**: We're removing `context.cms.scheduler` entirely. GraphQL resolvers will resolve use cases from `context.container`. + +### Phase 8: Update GraphQL Resolvers + +**File**: `src/graphql/index.ts` + +**Before (with context.cms.scheduler)**: +```typescript +const createCmsSchedule = async (_, args, context) => { + const model = await context.cms.getModel(args.modelId); + const scheduler = context.cms.scheduler(model); + return scheduler.schedule(args.id, args.input); +}; +``` + +**After (with container)**: +```typescript +const createCmsSchedule = async (_, args, context) => { + // Get model + const model = await context.cms.getModel(args.modelId); + + // Resolve use case from container + const scheduleUseCase = context.container.resolve(ScheduleRecordUseCase); + + // Execute with targetModel as parameter + const result = await scheduleUseCase.execute(model, args.id, args.input); + + return result; +}; + +const getCmsSchedule = async (_, args, context) => { + const model = await context.cms.getModel(args.modelId); + const getUseCase = context.container.resolve(GetScheduledRecordUseCase); + return getUseCase.execute(model, args.id); +}; + +const listCmsSchedules = async (_, args, context) => { + const model = await context.cms.getModel(args.modelId); + const listUseCase = context.container.resolve(ListScheduledRecordsUseCase); + return listUseCase.execute(model, args); +}; + +const cancelCmsSchedule = async (_, args, context) => { + const model = await context.cms.getModel(args.modelId); + const cancelUseCase = context.container.resolve(CancelScheduledRecordUseCase); + return cancelUseCase.execute(model, args.id); +}; +``` + +**Pattern**: +1. Get model from args +2. Resolve specific use case from container +3. Execute with model as first parameter + +## Dependency Chain Analysis + +### Before (Manual Instantiation) +``` +createScheduler (factory) + ├─ new ScheduleFetcher({ cms, targetModel, schedulerModel }) + ├─ new PublishScheduleAction({ + │ cms, + │ schedulerModel, + │ targetModel, + │ service, + │ getIdentity, + │ fetcher + │ }) + ├─ new UnpublishScheduleAction({ ... same ... }) + ├─ new ScheduleExecutor({ actions, fetcher }) + └─ new Scheduler({ fetcher, executor }) +``` + +### After (DI Container with Method Parameters) +``` +GraphQL Resolver + ├─ Get targetModel from args + └─ Container.resolve(ScheduleRecordUseCase) + │ + ├─ Inject: ScheduleExecutor + │ ├─ Inject: [ScheduleAction] (multiple) + │ │ │ + │ │ ├─ PublishScheduleAction + │ │ │ ├─ Inject: EventBridgeSchedulerService + │ │ │ ├─ Inject: SchedulerModel (instance) + │ │ │ ├─ Inject: IdentityProvider (factory) + │ │ │ └─ Inject: CMS Use Cases (TODO) + │ │ │ + │ │ └─ UnpublishScheduleAction + │ │ ├─ Inject: EventBridgeSchedulerService + │ │ ├─ Inject: SchedulerModel (instance) + │ │ ├─ Inject: IdentityProvider (factory) + │ │ └─ Inject: CMS Use Cases (TODO) + │ │ + │ └─ Inject: ScheduleFetcher + │ ├─ Inject: SchedulerModel (instance) + │ └─ Inject: CMS Use Cases (TODO) + │ + └─ Wrapped by: ValidateNotPrivateModelDecorator + └─ Validates targetModel.isPrivate + + → Execute: useCase.execute(targetModel, id, input) + │ + └─ targetModel passed as parameter through: + ScheduleRecordUseCase + → ScheduleExecutor.schedule(targetModel, ...) + → ScheduleAction.schedule(targetModel, ...) + → EventBridgeSchedulerService + → ScheduleFetcher.getScheduled(targetModel, ...) +``` + +**Key Differences**: +- No child containers needed +- `targetModel` passed as method parameter +- `schedulerModel` injected once as instance +- All use cases registered once in parent container +- Validation transparent via decorator + +## Benefits of Proposed Architecture + +1. **Single Responsibility**: Each use case has one clear responsibility + - `ScheduleRecordUseCase` - Only schedules records + - `CancelScheduledRecordUseCase` - Only cancels schedules + - `GetScheduledRecordUseCase` - Only retrieves single schedule + - `ListScheduledRecordsUseCase` - Only lists schedules + +2. **Testability**: Easy to mock dependencies via container + - Test each use case in isolation + - Mock only what each use case needs + +3. **Extensibility**: New schedule types = new action registration + - Add new actions without modifying use cases + - Actions are discovered via DI container + +4. **Consistency**: Matches ProcessRecords feature pattern + - Same DI patterns throughout codebase + - Same abstraction/implementation split + +5. **Separation of Concerns**: Clear abstraction boundaries + - Use cases don't know about AWS EventBridge + - Actions don't know about each other + - Service object is just a facade + +6. **Type Safety**: TypeScript enforces dependency contracts + - Container validates dependencies at registration + - Compile-time checking of dependency types + +7. **Maintainability**: Dependencies declared explicitly + - Easy to see what each component needs + - Refactoring dependencies is straightforward + +8. **Reusability**: Components can be reused in different contexts + - Use cases can be composed differently + - Actions can be used outside scheduler context + +9. **No God Objects**: No convenience wrappers that grow over time + - Each use case is resolved individually + - GraphQL resolvers explicitly choose which use case to call + - Prevents accidental dependencies between operations + +## Challenges and Solutions + +### Challenge 1: Model-Specific Context +**Problem**: Scheduler needs model context (targetModel varies per request, schedulerModel is constant) + +**Solution**: Pass `targetModel` as method parameter, inject `schedulerModel` as instance +- `targetModel` → method parameter (varies per request) +- `schedulerModel` → registered as instance (constant) + +### Challenge 2: CMS Use Cases Not Abstracted +**Problem**: Actions depend on CMS methods like `getEntryById`, `publishEntry`, etc. + +**Solution**: +- **Short-term**: Leave as TODOs in dependencies, inject CMS context +- **Long-term**: Create proper use case abstractions for all CMS operations + +### Challenge 3: External Dependencies (AWS Client, Manifest Config) +**Problem**: EventBridgeSchedulerService depends on AWS client factory and manifest config + +**Solution**: Register as instances during context setup: +```typescript +container.registerInstance(SchedulerServiceConfig, manifestConfig); +container.registerFactory(SchedulerServiceClientFactory, clientFactory); +``` + +### Challenge 4: Identity Provider +**Problem**: Actions need current user identity (runtime value) + +**Solution**: Register identity factory in parent container: +```typescript +container.registerFactory(IdentityProvider, () => security.getIdentity()); +``` + +### Challenge 5: Model Validation +**Problem**: Need to validate `targetModel.isPrivate` before operations + +**Solution**: Use decorator pattern (transparent to consumers): +```typescript +container.registerDecorator(ValidateNotPrivateModel); +``` + +### Challenge 6: Context God Object +**Problem**: Don't want to add `context.cms.scheduler` + +**Solution**: GraphQL resolvers resolve use cases directly from `context.container` + +## Migration Sequence + +### Step 1: Create Abstractions (No Breaking Changes) +- Create `features/Scheduler/abstractions.ts` +- Define all abstractions +- No existing code changes + +### Step 2: Refactor SchedulerService (Independent) +- Create SchedulerService abstraction and implementation +- Register in feature +- Test independently + +### Step 3: Refactor ScheduleFetcher (Minimal Dependencies) +- Create ScheduleFetcherUseCase +- Leave CMS dependencies as TODOs +- Register in feature + +### Step 4: Refactor Actions (Complex Dependencies) +- Create PublishScheduleAction with DI +- Create UnpublishScheduleAction with DI +- Leave CMS use cases as TODOs +- Register in feature + +### Step 5: Refactor ScheduleExecutor (Uses Actions + Fetcher) +- Create ScheduleExecutorUseCase +- Inject actions and fetcher +- Register in feature + +### Step 6: Refactor Scheduler (Uses Executor + Fetcher) +- Create SchedulerUseCase +- Inject executor and fetcher +- Register in feature + +### Step 7: Create Factory (Uses Container) +- Create SchedulerFactory +- Implement child container pattern +- Register in feature + +### Step 8: Update Context Integration +- Update `context.ts` to use factory +- Remove old `createScheduler` factory +- Test end-to-end + +### Step 9: Clean Up +- Remove old files from `scheduler/` directory +- Update imports throughout codebase +- Update tests + +## Testing Strategy + +### Unit Tests +- Test each use case in isolation with mocked dependencies +- Test actions with mocked services +- Test factory with mocked container + +### Integration Tests +- Test scheduler creation flow +- Test schedule/cancel operations end-to-end +- Test with real AWS EventBridge (optional) + +### Migration Tests +- Ensure behavior identical before/after migration +- Test all schedule types (publish, unpublish, immediate, past date, future date) +- Test error scenarios + +## Rollback Plan + +If migration causes issues: + +1. **Keep old code**: Don't delete old files until migration complete +2. **Feature flag**: Use environment variable to toggle old/new implementation +3. **Gradual rollout**: Test in dev → staging → production + +## Timeline Estimate + +- **Phase 1** (Abstractions): 2-3 hours +- **Phase 2** (Actions refactor): 4-6 hours +- **Phase 3** (Use cases): 3-4 hours +- **Phase 4** (Factory): 3-4 hours +- **Phase 5** (Feature registration): 1-2 hours +- **Phase 6** (SchedulerService): 2-3 hours +- **Phase 7** (Context integration): 2-3 hours +- **Testing**: 4-6 hours + +**Total**: ~20-30 hours + +## Open Questions + +1. **CMS Use Case Abstractions**: Should we create them now or leave as TODOs? + - **Recommendation**: Leave as TODOs, create separately when migrating CMS core + +2. **targetModel Parameter**: Should it be first or last parameter in execute() methods? + - **Recommendation**: First parameter - it's the primary context + +3. **Identity Management**: Should identity be injected or fetched on-demand? + - **Recommendation**: Register factory in parent container for on-demand access + +4. **EventBridge Service Layer**: Should SchedulerService stay in `service/` or move to `features/`? + - **Recommendation**: Move to `features/Scheduler/` and rename to `EventBridgeSchedulerService` for clarity + +5. **GraphQL Resolver Pattern**: Resolve use case every request or cache? + - **Recommendation**: Resolve every request - containers are fast and we avoid memory leaks + +6. **Validation Decorator Scope**: Apply to all use cases or just commands? + - **Recommendation**: Just commands (Schedule, Cancel) - queries don't mutate so don't need validation + +## Success Criteria + +- ✅ All scheduler operations work identically to before +- ✅ No manual instantiation (everything via DI container) +- ✅ All dependencies injected via DI container +- ✅ Tests pass with mocked dependencies +- ✅ New schedule types can be added via registration only +- ✅ Code follows same patterns as ProcessRecords feature +- ✅ Each use case has single responsibility with one `execute()` method +- ✅ No convenience wrapper/god objects +- ✅ No factory pattern needed +- ✅ No child containers needed +- ✅ GraphQL resolvers explicitly resolve individual use cases from `context.container` +- ✅ No `context.cms.scheduler` - avoiding god object pattern +- ✅ `targetModel` passed as method parameter +- ✅ Validation transparent via decorator + +## Future Enhancements + +After migration: + +1. **CMS Use Case Abstractions**: Create proper abstractions for all CMS operations +2. **Event Publishing**: Add domain events (ScheduleCreated, ScheduleCanceled, etc.) +3. **Error Handling**: Use Result pattern instead of throwing exceptions +4. **Validation**: Move validation to dedicated use cases/validators +5. **Decorators**: Add decorators for access control, logging, etc. +6. **Composite Actions**: Support complex schedule types with composite pattern + +## References + +- ProcessRecords feature implementation: `src/features/ProcessRecords/` +- DI Container documentation: `ai-context/di-container.md` +- Backend Developer Guide: `ai-context/backend-developer-guide.md` +- Current architecture: `ARCHITECTURE.md` From aee6c32449bdc6b7716264dca635058f5525d57b Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 15:06:27 +0100 Subject: [PATCH 33/71] wip: migrate headless cms scheduler --- .../api-headless-cms-scheduler/SCHEDULER.md | 2039 ++++++++++++----- .../src/hooks/index.ts | 1 + 2 files changed, 1425 insertions(+), 615 deletions(-) diff --git a/packages/api-headless-cms-scheduler/SCHEDULER.md b/packages/api-headless-cms-scheduler/SCHEDULER.md index 4eb28fc4a54..2a7afd79f0b 100644 --- a/packages/api-headless-cms-scheduler/SCHEDULER.md +++ b/packages/api-headless-cms-scheduler/SCHEDULER.md @@ -1,889 +1,1698 @@ -# Scheduler Migration Plan +# Scheduler Migration Plan - Generic Action Scheduler ## Overview -This document outlines the migration plan for refactoring the scheduler component (`src/scheduler/`) to utilize the DI container, abstractions, and the features architecture pattern. The goal is to align the scheduler implementation with the same patterns used in the ProcessRecords feature. +This document outlines the migration plan for refactoring the scheduler package from a CMS-specific implementation to a **generic action scheduler** that can be used by any application (Headless CMS, Mailer, Website Builder, etc.). The scheduler will use DI container, abstractions, and follow the same patterns as EventPublisher with EventHandlers. ## Current State Analysis -### Current Architecture +### Current Architecture (CMS-Specific) -**Factory Pattern (`createScheduler.ts`)** -- Factory function creates model-specific scheduler instances -- Manually instantiates actions: `new PublishScheduleAction()`, `new UnpublishScheduleAction()` -- No DI container usage - tight coupling -- Comment exists: `// TODO: inject actions!!!` (line 51) +**Package Name**: `@webiny/api-headless-cms-scheduler` -**Core Components** -1. **Scheduler** - Main interface (composition pattern) -2. **ScheduleExecutor** - Coordinates action execution -3. **ScheduleFetcher** - Retrieves schedule records -4. **Schedule Actions** - PublishScheduleAction, UnpublishScheduleAction (implement IScheduleAction) -5. **SchedulerService** - AWS EventBridge wrapper +**Current Design**: +- Tightly coupled to Headless CMS +- Hard-coded `ScheduleType` enum (`publish`, `unpublish`) +- `targetModel` is CMS model +- **Two different "action" patterns** (confusing): + 1. **Scheduling side** (`scheduler/actions/`): `PublishScheduleAction`, `UnpublishScheduleAction` - handle creating schedules + 2. **Execution side** (`handler/actions/`): `PublishHandlerAction`, `UnpublishHandlerAction` - handle executing schedules +- Factory pattern creates scheduler per model +- Manual instantiation with no DI +- `reschedule()` method mixed with `schedule()` logic -### Current Dependency Flow +**Current Flow - Scheduling Side**: ``` -createScheduler (factory) - ↓ -Manually creates: - - ScheduleFetcher - - PublishScheduleAction (with many dependencies) - - UnpublishScheduleAction (with many dependencies) - - ScheduleExecutor (with actions array) - - Scheduler (with fetcher + executor) +GraphQL Mutation + → context.cms.scheduler(model) + → scheduler.schedule(entryId, { type: "publish" }) + → ScheduleExecutor.schedule() + → PublishScheduleAction (decides: immediate vs. future, create vs. reschedule) + ├─ immediate → publishes entry directly + ├─ past date → updates metadata + publishes + └─ future date → creates DB entry + AWS EventBridge schedule +``` + +**Current Flow - Execution Side**: ``` +AWS EventBridge (at scheduled time) + → Lambda invocation + → Handler.handle() + → PublishHandlerAction.handle() + → cms.publishEntry() + → Delete schedule entry +``` + +### Problems with Current Implementation + +1. **Not Reusable** - Only works for CMS entries +2. **Hard-coded Types** - Can't schedule emails, page deletions, etc. +3. **Two Action Patterns** - Confusing: scheduling actions vs. execution actions +4. **Mixed Concerns** - Scheduling logic mixed with execution logic (immediate publish) +5. **Tight Coupling** - Actions know about CMS models +6. **No Abstraction** - Manual instantiation everywhere +7. **God Object** - `context.cms.scheduler` is growing +8. **No Extensibility** - Adding new action types requires code changes +9. **Reschedule Complexity** - Separate `reschedule()` method instead of smart `schedule()` + +## Proposed Architecture - Generic Scheduler + +### Core Concept + +The scheduler becomes a **generic action scheduler** similar to how EventPublisher works: + +- **EventPublisher** publishes events → **EventHandlers** handle them +- **Scheduler** schedules actions → **ScheduledActionHandlers** execute them + +### Key Design Decisions + +1. **Action Identifier**: Hierarchical string format `{namespace}/{entity}/{operation}` + - Examples: `"Cms/Entry/Publish"`, `"Mailer/Email/Send"`, `"Website/Page/Delete"` -## Problems with Current Implementation +2. **No CMS-specific logic in core** - All CMS logic moves to handlers -1. **Tight Coupling**: Actions are manually instantiated with all dependencies -2. **No Abstraction Layer**: No proper abstraction for Scheduler, Executor, or Fetcher -3. **Testing Difficulty**: Cannot easily mock dependencies -4. **Extensibility Issues**: Adding new schedule types requires modifying factory -5. **Inconsistent Patterns**: Handler layer uses DI but scheduler doesn't -6. **Repeated Dependency Injection**: Each action receives same CMS dependencies manually +3. **Apps register handlers** - Just like event handlers -## Proposed Architecture +4. **Method parameter pattern** - `actionId` passed as parameter (varies per request) + +5. **No god objects** - No `context.cms.scheduler`, use `context.container` to register and resolve implementations + +6. **Single action pattern** - Only `ScheduledActionHandler` (execution side) + - Scheduling side is generic use case logic (no action-specific behavior) + +7. **No separate reschedule** - `schedule()` detects existing schedules and updates them + +8. **No immediate execution in scheduler** - Apps use direct methods for immediate actions + +### Package Structure + +``` +packages/ +├── api-scheduler/ # Generic scheduler (new) +│ └── src/ +│ ├── shared/ +│ │ └── abstractions.ts # Shared abstractions (ScheduledActionHandler, IScheduledAction, etc.) +│ └── features/ +│ ├── ScheduleAction/ +│ │ ├── abstractions.ts +│ │ ├── ScheduleActionUseCase.ts +│ │ └── feature.ts +│ ├── CancelScheduledAction/ +│ │ ├── abstractions.ts +│ │ ├── CancelScheduledActionUseCase.ts +│ │ └── feature.ts +│ ├── GetScheduledAction/ +│ │ ├── abstractions.ts +│ │ ├── GetScheduledActionUseCase.ts +│ │ └── feature.ts +│ ├── ListScheduledActions/ +│ │ ├── abstractions.ts +│ │ ├── ListScheduledActionsUseCase.ts +│ │ └── feature.ts +│ └── ExecuteScheduledAction/ +│ ├── abstractions.ts +│ ├── ExecuteScheduledActionUseCase.ts +│ └── feature.ts +│ +└── api-headless-cms-scheduler/ # CMS handlers + └── src/ + └── features/ + └── scheduler/ + ├── handlers/ + │ ├── CmsEntryPublishHandler.ts + │ └── CmsEntryUnpublishHandler.ts + ├── graphql/ + │ └── resolvers.ts # CMS-specific GraphQL + ├── feature.ts + └── constants.ts +``` + +We need a new `api-headless-cms-scheduler` package for CMS-specific implementations, because `api-scheduler` internally depends on `api-headless-cms` for internal storage, and we would get a circular dependency. + +## Detailed Architecture ### Feature Structure ``` -src/features/Scheduler/ -├── abstractions.ts # All abstractions +src/ ├── index.ts # Exports -├── feature.ts # Feature registration -├── ScheduleExecutorUseCase.ts # Executor with DI -├── ScheduleFetcherUseCase.ts # Fetcher with DI -├── ScheduleRecordUseCase.ts # Command: Schedule a record -├── CancelScheduledRecordUseCase.ts # Command: Cancel a schedule -├── GetScheduledRecordUseCase.ts # Query: Get single schedule -├── ListScheduledRecordsUseCase.ts # Query: List schedules -├── ValidateNotPrivateModelDecorator.ts # Validation decorator -├── EventBridgeSchedulerService.ts # AWS EventBridge wrapper -└── actions/ - ├── PublishScheduleAction.ts # Publish action with DI - └── UnpublishScheduleAction.ts # Unpublish action with DI +└── features/ + ├── shared/ + │ └── abstractions.ts # Shared abstractions (ScheduledActionHandler, IScheduledAction, etc.) + ├── ScheduleAction/ + │ ├── abstractions.ts # ScheduleActionUseCase abstraction + │ ├── ScheduleActionUseCase.ts # Implementation + │ └── feature.ts # Feature registration + ├── CancelScheduledAction/ + │ ├── abstractions.ts # CancelScheduledActionUseCase abstraction + │ ├── CancelScheduledActionUseCase.ts # Implementation + │ └── feature.ts # Feature registration + ├── GetScheduledAction/ + │ ├── abstractions.ts # GetScheduledActionUseCase abstraction + │ ├── GetScheduledActionUseCase.ts # Implementation + │ └── feature.ts # Feature registration + ├── ListScheduledActions/ + │ ├── abstractions.ts # ListScheduledActionsUseCase abstraction + │ ├── ListScheduledActionsUseCase.ts # Implementation + │ └── feature.ts # Feature registration + └── ExecuteScheduledAction/ + ├── abstractions.ts # ExecuteScheduledActionUseCase abstraction + ├── ExecuteScheduledActionUseCase.ts # Implementation + └── feature.ts # Feature registration +``` + +### Consumer App Structure (Headless CMS Example) + +``` +packages/api-headless-cms-scheduler/src/features/scheduler/ +├── handlers/ +│ ├── CmsEntryPublishHandler.ts +│ └── CmsEntryUnpublishHandler.ts +├── graphql/ +│ └── resolvers.ts # CMS-specific GraphQL +├── feature.ts # Registers CMS handlers +└── constants.ts # Action ID constants +``` + +## Architecture Clarification: Scheduling vs. Execution + +### The Two Sides of Scheduling + +**OLD Architecture** had two confusing "action" patterns: +- `scheduler/actions/PublishScheduleAction` - **Scheduling side** (create schedule) +- `handler/actions/PublishHandlerAction` - **Execution side** (execute schedule) + +**NEW Architecture** has clear separation: + +#### 1. Scheduling Side (Generic Use Cases) + +**What**: Creating, updating, canceling schedules +**When**: User calls GraphQL mutation to schedule an action +**Where**: `ScheduleActionUseCase`, `CancelScheduledActionUseCase` + +**Logic (Generic)**: +``` +// ScheduleActionUseCase.execute() +1. Validate schedule date is in future +2. Check if schedule already exists + - If exists: UPDATE existing schedule (reschedule) + - If new: CREATE new schedule +3. Store schedule entry in database +4. Create/update EventBridge schedule +5. Return scheduled action ``` +**Key Point**: This code knows NOTHING about publishing, emails, or any specific action. It just manages schedules. + +**Files to DELETE from old architecture**: +- ❌ `scheduler/actions/PublishScheduleAction.ts` +- ❌ `scheduler/actions/UnpublishScheduleAction.ts` +- ❌ `scheduler/ScheduleExecutor.ts` +- ❌ `scheduler/ScheduleFetcher.ts` + +#### 2. Execution Side (Orchestration + Handlers) + +**What**: Executing scheduled actions when EventBridge triggers +**When**: EventBridge invokes Lambda at scheduled time +**Where**: `ExecuteScheduledActionUseCase` orchestrates, `ScheduledActionHandler` implementations execute + +**Logic (Orchestration - Generic)**: +``` +// ExecuteScheduledActionUseCase.execute() +1. Fetch schedule entry from storage +2. Set identity to the user who scheduled the action +3. Get target model/context +4. Find appropriate handler by actionId +5. Execute handler +6. Delete schedule entry on success +7. Update with error on failure +``` + +**Logic (Handler - Action-Specific)**: +``` +// CmsEntryPublishHandler.handle() +1. Parse action data (targetId, payload) +2. Execute business logic (publish entry, send email, etc.) +3. Return success/failure +``` + +**Key Point**: `ExecuteScheduledActionUseCase` handles orchestration (generic), handlers contain business logic (app-specific). + +**Files to KEEP and migrate**: +- ✅ `ProcessRecordsUseCase.ts` → rename to `ExecuteScheduledActionUseCase.ts` (generic orchestration) +- ✅ `PublishRecordAction.ts` → becomes `CmsEntryPublishHandler.ts` in api-headless-cms-scheduler +- ✅ `UnpublishRecordAction.ts` → becomes `CmsEntryUnpublishHandler.ts` in api-headless-cms-scheduler + +### What About `reschedule()`? + +**OLD**: Separate `reschedule()` method in `PublishScheduleAction` +```typescript +if (original) { + return action.reschedule(original, input); +} +return action.schedule(params); +``` + +**NEW**: Smart `schedule()` use case that detects existing schedules +```typescript +// ScheduleActionUseCase.execute() +const existing = await this.fetcher.get(scheduleId); + +if (existing) { + // UPDATE existing schedule + await this.updateEntry(...); + await this.eventBridge.update(...); +} else { + // CREATE new schedule + await this.createEntry(...); + await this.eventBridge.create(...); +} +``` + +**Key Point**: "Reschedule" is just "schedule with existing record". No separate method needed. + +### What About Immediate Execution? + +**OLD**: `PublishScheduleAction` handles immediate execution +```typescript +if (input.immediately) { + await this.cms.publishEntry(this.targetModel, targetId); + return createScheduleRecord(...); +} +``` + +**NEW**: Apps don't use scheduler for immediate execution + +**CMS GraphQL Resolver**: +```typescript +// For immediate publish +if (args.immediately) { + const publishUseCase = context.container.resolve(PublishEntryUseCase); + return publishUseCase.execute(model, entryId); +} + +// For scheduled publish +const scheduleUseCase = context.container.resolve(ScheduleActionUseCase); +return scheduleUseCase.execute( + "Cms/Entry/Publish", + entryId, + { scheduleOn: args.scheduleOn }, + { model } +); +``` + +**Key Point**: Scheduler is ONLY for future actions. Immediate actions use direct use cases. + ## Migration Steps -### Phase 1: Create Abstractions - -**File**: `features/Scheduler/abstractions.ts` - -Create the following abstractions: - -1. **ScheduleAction** - Abstraction for schedule actions (already exists as interface `IScheduleAction`) - ```typescript - export const ScheduleAction = createAbstraction("ScheduleAction"); - ``` - -2. **ScheduleFetcher** - Abstraction for fetching schedule records - ```typescript - export interface IScheduleFetcherUseCase { - getScheduled(targetId: string): Promise; - listScheduled(params: ISchedulerListParams): Promise; - } - export const ScheduleFetcher = createAbstraction("ScheduleFetcher"); - ``` - -3. **ScheduleExecutor** - Abstraction for executing schedules - ```typescript - export interface IScheduleExecutorUseCase { - schedule(targetId: string, input: ISchedulerInput): Promise; - cancel(id: string): Promise; - } - export const ScheduleExecutor = createAbstraction("ScheduleExecutor"); - ``` - -4. **Individual Use Cases** - One responsibility per use case - - **ScheduleRecord** - Create or update a schedule - ```typescript - export interface IScheduleRecordUseCase { - execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise; - } - export const ScheduleRecordUseCase = createAbstraction("ScheduleRecordUseCase"); - ``` - - **CancelScheduledRecord** - Cancel an existing schedule - ```typescript - export interface ICancelScheduledRecordUseCase { - execute(targetModel: CmsModel, id: string): Promise; - } - export const CancelScheduledRecordUseCase = createAbstraction("CancelScheduledRecordUseCase"); - ``` - - **GetScheduledRecord** - Get a single schedule - ```typescript - export interface IGetScheduledRecordUseCase { - execute(targetModel: CmsModel, id: string): Promise; - } - export const GetScheduledRecordUseCase = createAbstraction("GetScheduledRecordUseCase"); - ``` - - **ListScheduledRecords** - List schedules with filtering - ```typescript - export interface IListScheduledRecordsUseCase { - execute(targetModel: CmsModel, params: ISchedulerListParams): Promise; - } - export const ListScheduledRecordsUseCase = createAbstraction("ListScheduledRecordsUseCase"); - ``` - - **Note**: `targetModel` is passed as a method parameter, not injected via constructor. This is because the model varies per request while dependencies (executor, fetcher) remain constant. - -5. **SchedulerService** - AWS EventBridge abstraction (from service layer) - ```typescript - export const SchedulerService = createAbstraction("SchedulerService"); - ``` - - -### Phase 2: Refactor Actions to Use DI - -**Files**: -- `features/Scheduler/actions/PublishScheduleAction.ts` -- `features/Scheduler/actions/UnpublishScheduleAction.ts` - -**Current Dependencies** (manually injected): -- `service: ISchedulerService` -- `cms: PublishScheduleActionCms` -- `targetModel: CmsModel` ← Now passed as method parameter -- `schedulerModel: CmsModel` ← Injected as instance -- `getIdentity: () => CmsIdentity` ← Injected as factory -- `fetcher: IScheduleFetcher` - -**Proposed Approach**: - -```typescript -class PublishScheduleActionImpl implements ScheduleAction.Interface { - constructor( - private eventBridgeService: EventBridgeSchedulerService.Interface, - private schedulerModel: CmsModel, // Registered as instance - private getIdentity: () => CmsIdentity, // Registered as factory - // TODO: Create CMS use case abstractions for: - // - GetEntryByIdUseCase - // - PublishEntryUseCase - // - CreateEntryUseCase - // - UpdateEntryUseCase - // - DeleteEntryUseCase - ) {} +### Phase 1: Create Shared Abstractions - canHandle(input: ISchedulerInput): boolean { - return input.type === ScheduleType.publish; - } +**File**: `src/abstractions.ts` - async schedule(targetModel: CmsModel, params: IScheduleActionScheduleParams): Promise { - // targetModel passed as parameter - const { targetId, input, scheduleRecordId } = params; +#### 1.1 Scheduled Action Data Types - // TODO: Use GetEntryByIdUseCase with targetModel - const targetEntry = await this.getEntryById.execute(targetModel, targetId); +```typescript +/** + * Scheduled Action Record - The data stored for a scheduled action + */ +export interface IScheduledAction { + id: string; + actionId: string; // "Cms/Entry/Publish", "Mailer/Email/Send" + targetId: string; // Resource identifier (entry ID, email ID, etc.) + scheduledBy: Identity; + scheduledOn: Date; + payload?: any; // Action-specific data + error?: string; // Error if execution failed +} - // ... rest of implementation - } +/** + * Scheduler Input - When to schedule + */ +export interface ISchedulerInput { + scheduleOn: Date; // Future date (required) +} - // reschedule and cancel also receive targetModel as parameter +/** + * List Parameters + */ +export interface ISchedulerListParams { + where?: { + actionId?: string; + targetId?: string; + scheduledBy?: string; + scheduledOn_gte?: string; + scheduledOn_lte?: string; + }; + sort?: Array; + limit?: number; + after?: string; } -export const PublishScheduleAction = ScheduleAction.createImplementation({ - implementation: PublishScheduleActionImpl, - dependencies: [ - EventBridgeSchedulerService, - SchedulerModel, // Instance - IdentityProvider, // Factory - // TODO: Add CMS use case abstractions - ] -}); +export interface ISchedulerListResponse { + data: IScheduledAction[]; + meta: { + hasMoreItems: boolean; + totalCount: number; + cursor: string | null; + }; +} ``` -**Key Change**: `targetModel` is now a method parameter, not a constructor dependency. +#### 1.2 ScheduledActionHandler Abstraction -### Phase 3: Create Use Case Implementations +```typescript +/** + * ScheduledActionHandler - Similar to EventHandler pattern + * + * Each application (CMS, Mailer, etc.) implements handlers for their actions. + * This is the ONLY action abstraction needed. + */ +export interface IScheduledActionHandler { + /** + * Determines if this handler can handle the given action + */ + canHandle(actionId: string): boolean; + + /** + * Executes the scheduled action + */ + handle(action: IScheduledAction): Promise; +} -#### 3.1 ScheduleFetcherUseCase +export const ScheduledActionHandler = createAbstraction( + "ScheduledActionHandler" +); -**File**: `features/Scheduler/ScheduleFetcherUseCase.ts` +export namespace ScheduledActionHandler { + export type Interface = IScheduledActionHandler; +} +``` -**Current Dependencies**: -- `cms: Pick` -- `targetModel: CmsModel` ← Now passed as method parameter -- `schedulerModel: CmsModel` ← Injected as instance +#### 1.3 Core Use Case Abstractions -**Proposed**: ```typescript -class ScheduleFetcherUseCaseImpl implements ScheduleFetcher.Interface { +/** + * ScheduleActionUseCase - Schedule an action for future execution + * + * Handles both new schedules and rescheduling (update) automatically + */ +export interface IScheduleActionUseCase { + execute( + actionId: string, + targetId: string, + input: ISchedulerInput, + payload?: any + ): Promise; +} + +export const ScheduleActionUseCase = createAbstraction( + "ScheduleActionUseCase" +); + +export namespace ScheduleActionUseCase { + export type Interface = IScheduleActionUseCase; +} + +/** + * CancelScheduledActionUseCase - Cancel a scheduled action + */ +export interface ICancelScheduledActionUseCase { + execute(id: string): Promise; +} + +export const CancelScheduledActionUseCase = createAbstraction( + "CancelScheduledActionUseCase" +); + +export namespace CancelScheduledActionUseCase { + export type Interface = ICancelScheduledActionUseCase; +} + +/** + * GetScheduledActionUseCase - Get a single scheduled action + */ +export interface IGetScheduledActionUseCase { + execute(id: string): Promise; +} + +export const GetScheduledActionUseCase = createAbstraction( + "GetScheduledActionUseCase" +); + +export namespace GetScheduledActionUseCase { + export type Interface = IGetScheduledActionUseCase; +} + +/** + * ListScheduledActionsUseCase - List scheduled actions with filtering + */ +export interface IListScheduledActionsUseCase { + execute(params: ISchedulerListParams): Promise; +} + +export const ListScheduledActionsUseCase = createAbstraction( + "ListScheduledActionsUseCase" +); + +export namespace ListScheduledActionsUseCase { + export type Interface = IListScheduledActionsUseCase; +} +``` + +#### 1.4 Infrastructure Abstractions + +```typescript +/** + * ExecuteScheduledActionUseCase - Orchestrates execution of scheduled actions + * + * This is a use case, not just an executor. It handles: + * - Fetching schedule entry + * - Setting identity + * - Finding appropriate handler + * - Executing handler + * - Cleanup/error handling + */ +export interface IExecuteScheduledActionUseCase { + execute(payload: any): Promise; +} + +export const ExecuteScheduledActionUseCase = createAbstraction( + "ExecuteScheduledActionUseCase" +); + +export namespace ExecuteScheduledActionUseCase { + export type Interface = IExecuteScheduledActionUseCase; +} + +/** + * EventBridgeSchedulerService - AWS EventBridge wrapper + */ +export interface IEventBridgeSchedulerService { + create(params: { id: string; scheduleOn: Date; payload: any }): Promise; + update(params: { id: string; scheduleOn: Date; payload: any }): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} + +export const EventBridgeSchedulerService = createAbstraction( + "EventBridgeSchedulerService" +); + +export namespace EventBridgeSchedulerService { + export type Interface = IEventBridgeSchedulerService; +} +``` + +### Phase 2: Implement Features + +#### 2.1 ExecuteScheduledAction Feature + +**File**: `features/ExecuteScheduledAction/abstractions.ts` + +```typescript +import { createAbstraction } from "@webiny/feature/api"; + +export interface IExecuteScheduledActionUseCase { + execute(payload: any): Promise; +} + +export const ExecuteScheduledActionUseCase = createAbstraction( + "ExecuteScheduledActionUseCase" +); + +export namespace ExecuteScheduledActionUseCase { + export type Interface = IExecuteScheduledActionUseCase; +} +``` + +**File**: `features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts` + +```typescript +import { WebinyError } from "@webiny/error"; +import { ExecuteScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ScheduledActionHandler } from "~/abstractions.js"; +import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; + +/** + * Orchestrates execution of scheduled actions + * + * Responsibilities: + * - Fetch schedule entry from storage + * - Set identity to the user who scheduled the action + * - Find appropriate handler by actionId + * - Execute handler + * - Delete schedule entry on success or update with error + * + * This is similar to the current ProcessRecordsUseCase but generic. + */ +class ExecuteScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { constructor( - private schedulerModel: CmsModel, // Registered as instance - // TODO: Create abstractions for: - // - GetEntryByIdUseCase - // - ListLatestEntriesUseCase + private handlers: ScheduledActionHandler.Interface[], + private getScheduledAction: GetScheduledActionUseCase.Interface, + // TODO: Add identity context, CMS use cases (GetModel, UpdateEntry, DeleteEntry) ) {} - async getScheduled(targetModel: CmsModel, targetId: string): Promise { - const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); + async execute(payload: any): Promise { + // 1. Extract schedule ID from payload + const { id, actionId, targetId } = payload.ScheduledAction; - // TODO: Use GetEntryByIdUseCase - const entry = await this.getEntryById.execute(this.schedulerModel, scheduleRecordId); + // 2. Fetch schedule entry + const scheduledAction = await this.getScheduledAction.execute(id); - // Filter by targetModel - if (entry.values.targetModelId !== targetModel.modelId) { - return null; + if (!scheduledAction) { + throw new WebinyError(`Scheduled action not found: ${id}`, "NOT_FOUND"); } - return transformScheduleEntry(targetModel, entry); - } + // 3. Set identity to original scheduler + // TODO: this.identityContext.setIdentity(scheduledAction.scheduledBy); - async listScheduled(targetModel: CmsModel, params: ISchedulerListParams): Promise { - // TODO: Use ListLatestEntriesUseCase - const [data, meta] = await this.listLatestEntries.execute(this.schedulerModel, { - ...params, - where: { - ...params.where, - targetModelId: targetModel.modelId - } - }); + // 4. Find appropriate handler + const handler = this.handlers.find(h => h.canHandle(scheduledAction.actionId)); - return { - data: data.map(item => transformScheduleEntry(targetModel, item)), - meta - }; + if (!handler) { + // Update schedule entry with error + // TODO: await this.updateEntry(..., { error: "No handler found" }); + + throw new WebinyError( + `No handler found for action: ${scheduledAction.actionId}`, + "NO_HANDLER_FOUND", + { actionId: scheduledAction.actionId, targetId: scheduledAction.targetId } + ); + } + + // 5. Execute handler + try { + await handler.handle(scheduledAction); + } catch (ex) { + // Update schedule entry with error + // TODO: await this.updateEntry(..., { error: ex.message }); + throw ex; + } + + // 6. Delete schedule entry on success + // TODO: await this.deleteEntry(...); } } -export const ScheduleFetcherUseCase = ScheduleFetcher.createImplementation({ - implementation: ScheduleFetcherUseCaseImpl, +export const ExecuteScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: ExecuteScheduledActionUseCaseImpl, dependencies: [ - SchedulerModel, // Instance - // TODO: Add CMS use case abstractions + [ScheduledActionHandler, { multiple: true }], + GetScheduledActionUseCase, + // TODO: Add IdentityContext, CMS use cases ] }); ``` -**Note**: `targetModel` is passed as method parameter, `schedulerModel` is injected once. +**File**: `features/ExecuteScheduledAction/feature.ts` -#### 3.2 ScheduleExecutorUseCase +```typescript +import { createFeature } from "@webiny/feature/api"; +import { ExecuteScheduledActionUseCase } from "./ExecuteScheduledActionUseCase.js"; -**File**: `features/Scheduler/ScheduleExecutorUseCase.ts` +export const ExecuteScheduledActionFeature = createFeature({ + name: "ExecuteScheduledAction", + register(container) { + container.register(ExecuteScheduledActionUseCase); + } +}); +``` + +#### 2.2 ScheduleAction Feature -**Current Dependencies**: -- `actions: IScheduleAction[]` -- `fetcher: IScheduleFetcher` +**File**: `features/ScheduleAction/abstractions.ts` -**Proposed**: ```typescript -class ScheduleExecutorUseCaseImpl implements ScheduleExecutor.Interface { - constructor( - private actions: ScheduleAction.Interface[], - private fetcher: ScheduleFetcher.Interface - ) {} +import { createAbstraction } from "@webiny/feature/api"; +import type { IScheduledAction, ISchedulerInput } from "~/abstractions.js"; + +export interface IScheduleActionUseCase { + execute( + actionId: string, + targetId: string, + input: ISchedulerInput, + payload?: any + ): Promise; +} - async schedule(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { - const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); +export const ScheduleActionUseCase = createAbstraction( + "ScheduleActionUseCase" +); - // Pass targetModel to fetcher - const original = await this.fetcher.getScheduled(targetModel, targetId); +export namespace ScheduleActionUseCase { + export type Interface = IScheduleActionUseCase; +} +``` - const action = this.getAction(input.type); +**File**: `features/ScheduleAction/ScheduleActionUseCase.ts` - if (original) { - // Pass targetModel to action - return action.reschedule(targetModel, original, input); - } +```typescript +// Implementation similar to current ScheduleActionUseCase +// See Phase 3.1 below for full implementation +``` - // Pass targetModel to action - return action.schedule(targetModel, { - scheduleRecordId, - targetId, - input - }); +**File**: `features/ScheduleAction/feature.ts` + +```typescript +import { createFeature } from "@webiny/feature/api"; +import { ScheduleActionUseCase } from "./ScheduleActionUseCase.js"; + +export const ScheduleActionFeature = createFeature({ + name: "ScheduleAction", + register(container) { + container.register(ScheduleActionUseCase); } +}); +``` + +#### 2.3 Other Features - async cancel(targetModel: CmsModel, initialId: string): Promise { - const id = createScheduleRecordIdWithVersion(initialId); +The following features follow the same pattern (abstractions.ts + UseCase.ts + feature.ts): - // Pass targetModel to fetcher - const original = await this.fetcher.getScheduled(targetModel, id); +- **CancelScheduledAction** - See Phase 3.2 +- **GetScheduledAction** - See Phase 3.3 +- **ListScheduledActions** - See Phase 3.4 - if (!original) { - throw new WebinyError(`No scheduled record found for ID "${id}".`, "SCHEDULED_RECORD_NOT_FOUND"); +#### 2.4 Shared Infrastructure (EventBridgeSchedulerService) + +**Note**: `EventBridgeSchedulerService` is NOT a feature, it's infrastructure registered globally. + +**File**: `src/EventBridgeSchedulerService.ts` + +```typescript +import { EventBridgeSchedulerService as ServiceAbstraction } from "./abstractions.js"; +import { WebinyError } from "@webiny/error"; +import { + CreateScheduleCommand, + UpdateScheduleCommand, + DeleteScheduleCommand, + GetScheduleCommand +} from "@webiny/aws-sdk/client-scheduler"; + +/** + * AWS EventBridge Scheduler wrapper + */ +class EventBridgeSchedulerServiceImpl implements ServiceAbstraction.Interface { + constructor( + private getClient: (config?: any) => any, // Scheduler client factory + private config: { lambdaArn: string; roleArn: string } + ) {} + + async create(params: { id: string; scheduleOn: Date; payload: any }): Promise { + const { id, scheduleOn, payload } = params; + + if (scheduleOn <= new Date()) { + throw new WebinyError( + "Cannot schedule in the past", + "INVALID_SCHEDULE_DATE", + { scheduleOn } + ); } - const action = this.getAction(original.type); + const client = this.getClient(); + + await client.send(new CreateScheduleCommand({ + Name: id, + ScheduleExpression: `at(${scheduleOn.toISOString().replace(/\.\d{3}Z$/, "")})`, + FlexibleTimeWindow: { Mode: "OFF" }, + Target: { + Arn: this.config.lambdaArn, + RoleArn: this.config.roleArn, + Input: JSON.stringify(payload) + }, + ActionAfterCompletion: "DELETE" + })); + } - // Pass targetModel to action - await action.cancel(targetModel, original.id); + async update(params: { id: string; scheduleOn: Date; payload: any }): Promise { + // Similar to create but uses UpdateScheduleCommand + } - return original; + async delete(id: string): Promise { + const client = this.getClient(); + await client.send(new DeleteScheduleCommand({ Name: id })); } - private getAction(type: ScheduleType): ScheduleAction.Interface { - const action = this.actions.find(action => action.canHandle({ type })); - if (!action) { - throw new WebinyError(`No action found for input type "${type}".`, "NO_ACTION_FOUND"); + async exists(id: string): Promise { + try { + const client = this.getClient(); + await client.send(new GetScheduleCommand({ Name: id })); + return true; + } catch (ex) { + if (ex.name === "ResourceNotFoundException") { + return false; + } + throw ex; } - return action; } } -export const ScheduleExecutorUseCase = ScheduleExecutor.createImplementation({ - implementation: ScheduleExecutorUseCaseImpl, +export const EventBridgeSchedulerService = ServiceAbstraction.createImplementation({ + implementation: EventBridgeSchedulerServiceImpl, dependencies: [ - [ScheduleAction, { multiple: true }], - ScheduleFetcher + // Registered as instances/factories in context + SchedulerClientFactory, + SchedulerConfig ] }); ``` -**Key Change**: Executor receives `targetModel` and passes it down to fetcher and actions. +### Phase 3: Implement Use Cases + +#### 3.1 ScheduleActionUseCase -#### 3.3 Individual Use Case Implementations +**File**: `features/Scheduler/ScheduleActionUseCase.ts` -**ScheduleRecordUseCase** - Delegates to ScheduleExecutor ```typescript -class ScheduleRecordUseCaseImpl implements ScheduleRecordUseCase.Interface { - constructor(private executor: ScheduleExecutor.Interface) {} +import { ScheduleActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetScheduledActionUseCase } from "./abstractions.js"; +import { EventBridgeSchedulerService } from "./abstractions.js"; +import type { IScheduledAction, ISchedulerInput } from "./abstractions.js"; + +/** + * Schedules an action for future execution + * + * Flow: + * 1. Check if already scheduled (reschedule if exists) + * 2. Validate schedule date is in future + * 3. Create/update schedule entry in storage + * 4. Create/update EventBridge schedule + * + * Note: Does NOT handle immediate execution - apps use direct use cases for that + */ +class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getScheduledAction: GetScheduledActionUseCase.Interface, + private eventBridge: EventBridgeSchedulerService.Interface, + private getIdentity: () => Identity, // Factory + // TODO: Add CreateEntryUseCase, UpdateEntryUseCase + ) {} + + async execute( + actionId: string, + targetId: string, + input: ISchedulerInput, + payload?: any + ): Promise { + const identity = this.getIdentity(); + + // Generate schedule ID + const scheduleId = this.generateScheduleId(actionId, targetId); + + // Check if already scheduled (for reschedule logic) + const existing = await this.getScheduledAction.execute(scheduleId); + + if (existing) { + // RESCHEDULE: Update existing schedule + await this.updateEntry(schedulerModel, scheduleId, { + scheduledBy: identity, + scheduledOn: input.scheduleOn, + payload + }); + + await this.eventBridge.update({ + id: scheduleId, + scheduleOn: input.scheduleOn, + payload: { + scheduleId, + actionId, + targetId + } + }); + + return { + ...existing, + scheduledBy: identity, + scheduledOn: input.scheduleOn, + payload + }; + } + + // CREATE: New schedule + const scheduledAction: IScheduledAction = { + id: scheduleId, + actionId, + targetId, + scheduledBy: identity, + scheduledOn: input.scheduleOn, + payload + }; + + // TODO: Use CreateEntryUseCase + await this.createEntry(schedulerModel, scheduledAction); + + // Create EventBridge schedule + try { + await this.eventBridge.create({ + id: scheduleId, + scheduleOn: input.scheduleOn, + payload: { + scheduleId, + actionId, + targetId + } + }); + } catch (ex) { + // Rollback - delete entry if EventBridge fails + await this.deleteEntry(schedulerModel, scheduleId); + throw ex; + } - async execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { - return this.executor.schedule(targetModel, targetId, input); + return scheduledAction; + } + + private generateScheduleId(actionId: string, targetId: string): string { + // Create unique ID from actionId + targetId + return `${actionId.replace(/\//g, "_")}_${targetId}`; } } -export const ScheduleRecordUseCaseImplementation = ScheduleRecordUseCase.createImplementation({ - implementation: ScheduleRecordUseCaseImpl, - dependencies: [ScheduleExecutor] +export const ScheduleActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: ScheduleActionUseCaseImpl, + dependencies: [ + GetScheduledActionUseCase, + EventBridgeSchedulerService, + IdentityProvider, // Factory + // TODO: Add CMS use cases + ] }); ``` -**CancelScheduledRecordUseCase** - Delegates to ScheduleExecutor +#### 3.2 CancelScheduledActionUseCase + +**File**: `features/Scheduler/CancelScheduledActionUseCase.ts` + ```typescript -class CancelScheduledRecordUseCaseImpl implements CancelScheduledRecordUseCase.Interface { - constructor(private executor: ScheduleExecutor.Interface) {} +import { CancelScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetScheduledActionUseCase } from "./abstractions.js"; +import { EventBridgeSchedulerService } from "./abstractions.js"; +import type { IScheduledAction } from "./abstractions.js"; +import { WebinyError } from "@webiny/error"; + +/** + * Cancels a scheduled action + * + * Flow: + * 1. Fetch scheduled action + * 2. Delete EventBridge schedule + * 3. Delete storage entry + */ +class CancelScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getScheduledAction: GetScheduledActionUseCase.Interface, + private eventBridge: EventBridgeSchedulerService.Interface, + // TODO: Add DeleteEntryUseCase + ) {} + + async execute(id: string): Promise { + const scheduledAction = await this.getScheduledAction.execute(id); - async execute(targetModel: CmsModel, id: string): Promise { - return this.executor.cancel(targetModel, id); + if (!scheduledAction) { + throw new WebinyError( + `Scheduled action not found: ${id}`, + "SCHEDULED_ACTION_NOT_FOUND", + { id } + ); + } + + // Delete EventBridge schedule + try { + await this.eventBridge.delete(id); + } catch (ex) { + // Continue even if EventBridge delete fails + console.error("Failed to delete EventBridge schedule:", ex); + } + + // Delete storage entry + // TODO: Use DeleteEntryUseCase + await this.deleteEntry(schedulerModel, id); + + return scheduledAction; } } -export const CancelScheduledRecordUseCaseImplementation = CancelScheduledRecordUseCase.createImplementation({ - implementation: CancelScheduledRecordUseCaseImpl, - dependencies: [ScheduleExecutor] +export const CancelScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: CancelScheduledActionUseCaseImpl, + dependencies: [ + GetScheduledActionUseCase, + EventBridgeSchedulerService, + // TODO: Add CMS use cases + ] }); ``` -**GetScheduledRecordUseCase** - Delegates to ScheduleFetcher +#### 3.3 GetScheduledActionUseCase + +**File**: `features/Scheduler/GetScheduledActionUseCase.ts` + ```typescript -class GetScheduledRecordUseCaseImpl implements GetScheduledRecordUseCase.Interface { - constructor(private fetcher: ScheduleFetcher.Interface) {} +import { GetScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import type { IScheduledAction } from "./abstractions.js"; + +/** + * Gets a single scheduled action by ID + * + * Fetches schedule entry from CMS storage and transforms to IScheduledAction + */ +class GetScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + // TODO: Inject GetEntryByIdUseCase from CMS + // TODO: Inject SchedulerModel instance + ) {} - async execute(targetModel: CmsModel, id: string): Promise { - return this.fetcher.getScheduled(targetModel, id); + async execute(id: string): Promise { + try { + // TODO: const entry = await this.getEntryById.execute(schedulerModel, id); + // TODO: return this.transformEntry(entry); + } catch (ex) { + if (ex.code === "NOT_FOUND") { + return null; + } + throw ex; + } + } + + private transformEntry(entry: any): IScheduledAction { + return { + id: entry.id, + actionId: entry.values.actionId, + targetId: entry.values.targetId, + scheduledBy: entry.values.scheduledBy, + scheduledOn: new Date(entry.values.scheduledOn), + payload: entry.values.payload, + error: entry.values.error + }; } } -export const GetScheduledRecordUseCaseImplementation = GetScheduledRecordUseCase.createImplementation({ - implementation: GetScheduledRecordUseCaseImpl, - dependencies: [ScheduleFetcher] +export const GetScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetScheduledActionUseCaseImpl, + dependencies: [ + // TODO: Add CMS use cases + ] }); ``` -**ListScheduledRecordsUseCase** - Delegates to ScheduleFetcher +#### 3.4 ListScheduledActionsUseCase + +**File**: `features/Scheduler/ListScheduledActionsUseCase.ts` + ```typescript -class ListScheduledRecordsUseCaseImpl implements ListScheduledRecordsUseCase.Interface { - constructor(private fetcher: ScheduleFetcher.Interface) {} +import { ListScheduledActionsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import type { ISchedulerListParams, ISchedulerListResponse } from "./abstractions.js"; + +/** + * Lists scheduled actions with filtering + * + * Fetches schedule entries from CMS storage and transforms to IScheduledAction[] + */ +class ListScheduledActionsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + // TODO: Inject ListLatestEntriesUseCase from CMS + // TODO: Inject SchedulerModel instance + ) {} + + async execute(params: ISchedulerListParams): Promise { + // TODO: Use ListLatestEntriesUseCase + const [data, meta] = await this.listLatestEntries.execute(schedulerModel, { + where: params.where, + sort: params.sort, + limit: params.limit, + after: params.after + }); - async execute(targetModel: CmsModel, params: ISchedulerListParams): Promise { - return this.fetcher.listScheduled(targetModel, params); + return { + data: data.map(item => this.transformEntry(item)), + meta + }; + } + + private transformEntry(entry: any): IScheduledAction { + return { + id: entry.id, + actionId: entry.values.actionId, + targetId: entry.values.targetId, + scheduledBy: entry.values.scheduledBy, + scheduledOn: new Date(entry.values.scheduledOn), + payload: entry.values.payload, + error: entry.values.error + }; } } -export const ListScheduledRecordsUseCaseImplementation = ListScheduledRecordsUseCase.createImplementation({ - implementation: ListScheduledRecordsUseCaseImpl, - dependencies: [ScheduleFetcher] +export const ListScheduledActionsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListScheduledActionsUseCaseImpl, + dependencies: [ + // TODO: Add CMS use cases + ] }); ``` -**Key Change**: All use cases pass `targetModel` through to their dependencies. +### Phase 4: Register All Features in Context +**File**: `src/context.ts` -### Phase 4: Add Model Validation Decorator +```typescript +import { ExecuteScheduledActionFeature } from "~/features/ExecuteScheduledAction/feature.js"; +import { ScheduleActionFeature } from "~/features/ScheduleAction/feature.js"; +import { CancelScheduledActionFeature } from "~/features/CancelScheduledAction/feature.js"; +import { GetScheduledActionFeature } from "~/features/GetScheduledAction/feature.js"; +import { ListScheduledActionsFeature } from "~/features/ListScheduledActions/feature.js"; +import { EventBridgeSchedulerService } from "~/EventBridgeSchedulerService.js"; + +// Register all features +ExecuteScheduledActionFeature.register(context.container); +ScheduleActionFeature.register(context.container); +CancelScheduledActionFeature.register(context.container); +GetScheduledActionFeature.register(context.container); +ListScheduledActionsFeature.register(context.container); + +// Register shared infrastructure +context.container.register(EventBridgeSchedulerService).inSingletonScope(); -**File**: `features/Scheduler/ValidateNotPrivateModelDecorator.ts` +// Register manifest-based instances +context.container.registerInstance(SchedulerConfig, { + lambdaArn: manifest.scheduler.lambdaArn, + roleArn: manifest.scheduler.roleArn +}); -**Purpose**: Validate that `targetModel` is not private before executing any schedule operation. +context.container.registerInstance(SchedulerModel, schedulerModel); -```typescript -class ValidateNotPrivateModelDecorator implements ScheduleRecordUseCase.Interface { - constructor(private decoratee: ScheduleRecordUseCase.Interface) {} +// Register AWS client factory +context.container.registerFactory(SchedulerClientFactory, () => getClient); - async execute(targetModel: CmsModel, targetId: string, input: ISchedulerInput): Promise { - if (targetModel.isPrivate) { - throw new WebinyError( - "Cannot schedule operations on private models.", - "PRIVATE_MODEL_ERROR", - { modelId: targetModel.modelId } - ); - } +// Register identity provider +context.container.registerFactory(IdentityProvider, () => security.getIdentity()); +``` - return this.decoratee.execute(targetModel, targetId, input); - } -} +**Note**: Each feature is self-contained and registers only its own use case. This follows the pattern used in other packages where each feature is independent. -export const ValidateNotPrivateModel = ScheduleRecordUseCase.createDecorator({ - decorator: ValidateNotPrivateModelDecorator, - dependencies: [] -}); -``` +### Phase 5: Create CMS Handlers (Consumer App) + +**File**: `packages/api-headless-cms/src/features/scheduler/handlers/CmsEntryPublishHandler.ts` -**Registration**: ```typescript -// In feature.ts -container.registerDecorator(ValidateNotPrivateModel); -``` +import { ScheduledActionHandler } from "@webiny/api-scheduler"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { PublishEntryUseCase } from "~/features/contentEntry/PublishEntry/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { CMS_ENTRY_PUBLISH_ACTION } from "../constants.js"; + +/** + * Handles scheduled publish actions for CMS entries + * + * Action ID: "Cms/Entry/Publish" + */ +class CmsEntryPublishHandlerImpl implements ScheduledActionHandler.Interface { + constructor( + private publishEntry: PublishEntryUseCase.Interface, + private getModel: GetModelUseCase.Interface + ) {} -**Note**: This decorator only needs to be registered for command use cases (ScheduleRecordUseCase, CancelScheduledRecordUseCase). Query use cases (Get, List) don't need validation since they're read-only. + canHandle(actionId: string): boolean { + return actionId === CMS_ENTRY_PUBLISH_ACTION; + } -### Phase 5: Create Feature Registration + async handle(action: IScheduledAction): Promise { + // Parse targetId to extract model and entry + // Format: "modelId#version" e.g., "product#0001" + const [modelId, version] = action.targetId.split("#"); -**File**: `features/Scheduler/feature.ts` + // Get model (could be cached in payload for optimization) + const model = action.payload?.model || await this.getModel.execute(modelId); -```typescript -export const SchedulerFeature = createFeature({ - name: "Scheduler", - register(container) { - // Register AWS EventBridge service layer - // TODO: SchedulerService needs to be refactored to use abstraction - // container.register(EventBridgeSchedulerServiceImpl).inSingletonScope(); - - // Register infrastructure components - container.register(ScheduleFetcherUseCase); - container.register(ScheduleExecutorUseCase); - - // Register individual use cases - container.register(ScheduleRecordUseCaseImplementation); - container.register(CancelScheduledRecordUseCaseImplementation); - container.register(GetScheduledRecordUseCaseImplementation); - container.register(ListScheduledRecordsUseCaseImplementation); - - // Register action implementations - container.register(PublishScheduleAction); - container.register(UnpublishScheduleAction); - - // Register validation decorator for commands - container.registerDecorator(ValidateNotPrivateModel); + // Execute publish + const result = await this.publishEntry.execute(model, action.targetId); + + if (result.isFail()) { + throw result.error; + } } +} + +export const CmsEntryPublishHandler = ScheduledActionHandler.createImplementation({ + implementation: CmsEntryPublishHandlerImpl, + dependencies: [ + PublishEntryUseCase, + GetModelUseCase + ] }); ``` -### Phase 6: Refactor SchedulerService - -**File**: `features/Scheduler/SchedulerServiceImpl.ts` - -**Current State**: Class with constructor injection of client and config +**File**: `packages/api-headless-cms/src/features/scheduler/handlers/CmsEntryUnpublishHandler.ts` -**Proposed**: ```typescript -class SchedulerServiceImpl implements SchedulerService.Interface { +import { ScheduledActionHandler } from "@webiny/api-scheduler"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { UnpublishEntryUseCase } from "~/features/contentEntry/UnpublishEntry/index.js"; +import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; +import { CMS_ENTRY_UNPUBLISH_ACTION } from "../constants.js"; + +/** + * Handles scheduled unpublish actions for CMS entries + * + * Action ID: "Cms/Entry/Unpublish" + */ +class CmsEntryUnpublishHandlerImpl implements ScheduledActionHandler.Interface { constructor( - private getClient: (config?: SchedulerClientConfig) => Pick, - private config: ISchedulerServiceConfig + private unpublishEntry: UnpublishEntryUseCase.Interface, + private getModel: GetModelUseCase.Interface ) {} - // ... existing methods + canHandle(actionId: string): boolean { + return actionId === CMS_ENTRY_UNPUBLISH_ACTION; + } + + async handle(action: IScheduledAction): Promise { + const [modelId, version] = action.targetId.split("#"); + const model = action.payload?.model || await this.getModel.execute(modelId); + + const result = await this.unpublishEntry.execute(model, action.targetId); + + if (result.isFail()) { + throw result.error; + } + } } -export const SchedulerServiceImplementation = SchedulerService.createImplementation({ - implementation: SchedulerServiceImpl, +export const CmsEntryUnpublishHandler = ScheduledActionHandler.createImplementation({ + implementation: CmsEntryUnpublishHandlerImpl, dependencies: [ - // TODO: How to inject AWS client factory? - // TODO: How to inject config (from manifest)? + UnpublishEntryUseCase, + GetModelUseCase ] }); ``` -**Challenge**: `getClient` and `config` come from external sources (manifest, AWS SDK). +**File**: `packages/api-headless-cms/src/features/scheduler/constants.ts` -**Solution**: Register as instances in context setup: ```typescript -// In context.ts after loading manifest -container.registerInstance(SchedulerServiceConfig, { - lambdaArn: manifest.scheduler.lambdaArn, - roleArn: manifest.scheduler.roleArn -}); -container.registerFactory(SchedulerServiceClientFactory, () => getClient); +/** + * CMS Scheduled Action IDs + */ +export const CMS_ENTRY_PUBLISH_ACTION = "Cms/Entry/Publish"; +export const CMS_ENTRY_UNPUBLISH_ACTION = "Cms/Entry/Unpublish"; ``` -### Phase 7: Update Context Integration +**File**: `packages/api-headless-cms/src/features/scheduler/feature.ts` -**File**: `src/context.ts` - -**Current State**: ```typescript -const scheduler = await createScheduler({ - security, - cms, - service: schedulerService, - schedulerModel +import { createFeature } from "@webiny/feature/api"; +import { CmsEntryPublishHandler } from "./handlers/CmsEntryPublishHandler.js"; +import { CmsEntryUnpublishHandler } from "./handlers/CmsEntryUnpublishHandler.js"; + +/** + * CMS Scheduler Handlers Feature + * + * Registers CMS-specific scheduled action handlers + */ +export const CmsSchedulerHandlersFeature = createFeature({ + name: "CmsSchedulerHandlers", + register(container) { + container.register(CmsEntryPublishHandler); + container.register(CmsEntryUnpublishHandler); + } }); -context.cms.scheduler = scheduler; ``` -**Proposed**: +### Phase 6: Update Context Integration + +**File**: `src/context.ts` (Generic Scheduler Package) + ```typescript -// Register feature +// Register scheduler feature SchedulerFeature.register(context.container); // Register manifest-based instances -container.registerInstance(SchedulerServiceConfig, manifestConfig); +container.registerInstance(SchedulerConfig, { + lambdaArn: manifest.scheduler.lambdaArn, + roleArn: manifest.scheduler.roleArn +}); + container.registerInstance(SchedulerModel, schedulerModel); -// Register identity provider as factory +// Register AWS client factory +container.registerFactory(SchedulerClientFactory, () => getClient); + +// Register identity provider container.registerFactory(IdentityProvider, () => security.getIdentity()); -// No context.cms.scheduler - GraphQL resolvers use container directly +// No context.cms.scheduler - apps use container directly ``` -**Note**: We're removing `context.cms.scheduler` entirely. GraphQL resolvers will resolve use cases from `context.container`. +**File**: `packages/api-headless-cms/src/context.ts` (Consumer App) + +```typescript +// Register CMS handlers +CmsSchedulerHandlersFeature.register(context.container); +``` -### Phase 8: Update GraphQL Resolvers +### Phase 7: Update GraphQL Resolvers -**File**: `src/graphql/index.ts` +**File**: `packages/api-headless-cms/src/features/scheduler/graphql/resolvers.ts` -**Before (with context.cms.scheduler)**: +**Before (old pattern):** ```typescript const createCmsSchedule = async (_, args, context) => { const model = await context.cms.getModel(args.modelId); const scheduler = context.cms.scheduler(model); - return scheduler.schedule(args.id, args.input); + return scheduler.schedule(args.id, { type: "publish", scheduleOn: args.scheduleOn }); }; ``` -**After (with container)**: +**After (new pattern):** ```typescript +import { ScheduleActionUseCase } from "@webiny/api-scheduler"; +import { PublishEntryUseCase } from "~/features/contentEntry/PublishEntry/index.js"; +import { CMS_ENTRY_PUBLISH_ACTION } from "../constants.js"; + const createCmsSchedule = async (_, args, context) => { // Get model const model = await context.cms.getModel(args.modelId); - // Resolve use case from container - const scheduleUseCase = context.container.resolve(ScheduleRecordUseCase); + // Handle immediate execution (not via scheduler) + if (args.immediately) { + const publishUseCase = context.container.resolve(PublishEntryUseCase); + const result = await publishUseCase.execute(model, args.id); + + if (result.isFail()) { + throw result.error; + } + + return result.value; + } + + // Schedule for future + const scheduleUseCase = context.container.resolve(ScheduleActionUseCase); - // Execute with targetModel as parameter - const result = await scheduleUseCase.execute(model, args.id, args.input); + const result = await scheduleUseCase.execute( + CMS_ENTRY_PUBLISH_ACTION, // "Cms/Entry/Publish" + args.id, // Entry ID e.g., "product#0001" + { scheduleOn: args.scheduleOn }, // When to schedule + { model } // Payload (optional, for optimization) + ); return result; }; const getCmsSchedule = async (_, args, context) => { - const model = await context.cms.getModel(args.modelId); - const getUseCase = context.container.resolve(GetScheduledRecordUseCase); - return getUseCase.execute(model, args.id); + const getUseCase = context.container.resolve(GetScheduledActionUseCase); + return getUseCase.execute(args.id); }; const listCmsSchedules = async (_, args, context) => { - const model = await context.cms.getModel(args.modelId); - const listUseCase = context.container.resolve(ListScheduledRecordsUseCase); - return listUseCase.execute(model, args); + const listUseCase = context.container.resolve(ListScheduledActionsUseCase); + return listUseCase.execute({ + where: { + actionId: args.actionId, // Filter by action ID + ...args.where + }, + sort: args.sort, + limit: args.limit, + after: args.after + }); }; const cancelCmsSchedule = async (_, args, context) => { - const model = await context.cms.getModel(args.modelId); - const cancelUseCase = context.container.resolve(CancelScheduledRecordUseCase); - return cancelUseCase.execute(model, args.id); + const cancelUseCase = context.container.resolve(CancelScheduledActionUseCase); + return cancelUseCase.execute(args.id); +}; +``` + +**Key Points**: +- CMS apps have their own GraphQL resolvers (not generic) +- Immediate execution bypasses scheduler entirely +- Each app wraps generic use cases with their own business logic + +### Phase 8: Update Event Handler (Execution Side) + +**File**: `src/handler/index.ts` + +```typescript +import { createEventHandler } from "@webiny/handler-aws/raw/index.js"; +import { ExecuteScheduledActionUseCase } from "~/features/Scheduler/index.js"; + +export const createScheduledActionEventHandler = () => { + return createEventHandler({ + canHandle: event => { + return event?.ScheduledAction?.id && event?.ScheduledAction?.actionId; + }, + handle: async params => { + const { payload, context } = params; + + // Resolve use case from container + const executeUseCase = context.container.resolve(ExecuteScheduledActionUseCase); + + // Execute (this handles everything: fetch, identity, find handler, execute, cleanup) + await executeUseCase.execute(payload); + } + }); }; ``` -**Pattern**: -1. Get model from args -2. Resolve specific use case from container -3. Execute with model as first parameter +**Key Points**: +- Event handler is now extremely simple - just delegates to use case +- All orchestration logic is in `ExecuteScheduledActionUseCase` +- Follows single responsibility principle ## Dependency Chain Analysis -### Before (Manual Instantiation) +### Before (Manual Instantiation, CMS-Specific) ``` createScheduler (factory) ├─ new ScheduleFetcher({ cms, targetModel, schedulerModel }) - ├─ new PublishScheduleAction({ - │ cms, - │ schedulerModel, - │ targetModel, - │ service, - │ getIdentity, - │ fetcher - │ }) - ├─ new UnpublishScheduleAction({ ... same ... }) + ├─ new PublishScheduleAction({ cms, schedulerModel, targetModel, service, getIdentity, fetcher }) + ├─ new UnpublishScheduleAction({ ... }) ├─ new ScheduleExecutor({ actions, fetcher }) └─ new Scheduler({ fetcher, executor }) ``` -### After (DI Container with Method Parameters) +### After (DI Container, Generic) ``` -GraphQL Resolver - ├─ Get targetModel from args - └─ Container.resolve(ScheduleRecordUseCase) +CMS GraphQL Resolver + ├─ Get model from args + ├─ IF immediate: Container.resolve(PublishEntryUseCase) + └─ IF scheduled: Container.resolve(ScheduleActionUseCase) + │ + ├─ Inject: GetScheduledActionUseCase + │ └─ Inject: CMS Use Cases (GetEntryById, SchedulerModel) + │ + ├─ Inject: EventBridgeSchedulerService + │ ├─ Inject: SchedulerClientFactory (factory) + │ └─ Inject: SchedulerConfig (instance) + │ + └─ Inject: IdentityProvider (factory) + + → Execute: useCase.execute(actionId, targetId, input, payload) + │ + └─ actionId = "Cms/Entry/Publish" (passed as parameter) + +Event Handler (when schedule executes) + └─ Container.resolve(ExecuteScheduledActionUseCase) + │ + ├─ Inject: GetScheduledActionUseCase + │ └─ Inject: CMS Use Cases (GetEntryById, SchedulerModel) │ - ├─ Inject: ScheduleExecutor - │ ├─ Inject: [ScheduleAction] (multiple) - │ │ │ - │ │ ├─ PublishScheduleAction - │ │ │ ├─ Inject: EventBridgeSchedulerService - │ │ │ ├─ Inject: SchedulerModel (instance) - │ │ │ ├─ Inject: IdentityProvider (factory) - │ │ │ └─ Inject: CMS Use Cases (TODO) - │ │ │ - │ │ └─ UnpublishScheduleAction - │ │ ├─ Inject: EventBridgeSchedulerService - │ │ ├─ Inject: SchedulerModel (instance) - │ │ ├─ Inject: IdentityProvider (factory) - │ │ └─ Inject: CMS Use Cases (TODO) + ├─ Inject: [ScheduledActionHandler] (multiple) + │ │ + │ ├─ CmsEntryPublishHandler (from api-headless-cms) + │ │ ├─ Inject: PublishEntryUseCase + │ │ └─ Inject: GetModelUseCase │ │ - │ └─ Inject: ScheduleFetcher - │ ├─ Inject: SchedulerModel (instance) - │ └─ Inject: CMS Use Cases (TODO) + │ ├─ CmsEntryUnpublishHandler (from api-headless-cms) + │ │ ├─ Inject: UnpublishEntryUseCase + │ │ └─ Inject: GetModelUseCase + │ │ + │ └─ MailerEmailSendHandler (from api-mailer - future) + │ └─ Inject: EmailService │ - └─ Wrapped by: ValidateNotPrivateModelDecorator - └─ Validates targetModel.isPrivate + └─ Inject: IdentityContext, CMS Use Cases (UpdateEntry, DeleteEntry, etc.) - → Execute: useCase.execute(targetModel, id, input) + → Execute: executeUseCase.execute(payload) │ - └─ targetModel passed as parameter through: - ScheduleRecordUseCase - → ScheduleExecutor.schedule(targetModel, ...) - → ScheduleAction.schedule(targetModel, ...) - → EventBridgeSchedulerService - → ScheduleFetcher.getScheduled(targetModel, ...) + ├─ Fetches schedule entry + ├─ Sets identity to scheduler + ├─ Finds handler by actionId + ├─ Calls handler.handle(scheduledAction) + └─ Cleanup (delete or update with error) ``` -**Key Differences**: -- No child containers needed -- `targetModel` passed as method parameter -- `schedulerModel` injected once as instance -- All use cases registered once in parent container -- Validation transparent via decorator +**Key Differences:** +- Generic `actionId` instead of CMS-specific `targetModel` +- Handlers registered by consumer apps, not built into scheduler +- No factory pattern needed +- Parallel to EventPublisher/EventHandler pattern +- Immediate execution bypasses scheduler entirely -## Benefits of Proposed Architecture +## Files to Delete vs. Migrate -1. **Single Responsibility**: Each use case has one clear responsibility - - `ScheduleRecordUseCase` - Only schedules records - - `CancelScheduledRecordUseCase` - Only cancels schedules - - `GetScheduledRecordUseCase` - Only retrieves single schedule - - `ListScheduledRecordsUseCase` - Only lists schedules +### DELETE (Old Scheduling Logic) +- ❌ `scheduler/actions/PublishScheduleAction.ts` +- ❌ `scheduler/actions/UnpublishScheduleAction.ts` +- ❌ `scheduler/ScheduleExecutor.ts` +- ❌ `scheduler/Scheduler.ts` +- ❌ `scheduler/createScheduler.ts` -2. **Testability**: Easy to mock dependencies via container - - Test each use case in isolation - - Mock only what each use case needs +### MIGRATE to Generic +- ✅ `service/SchedulerService.ts` → `EventBridgeSchedulerService.ts` (generic) -3. **Extensibility**: New schedule types = new action registration - - Add new actions without modifying use cases - - Actions are discovered via DI container +### MIGRATE to CMS Package +- ✅ `handler/actions/PublishHandlerAction.ts` → `api-headless-cms/src/features/scheduler/handlers/CmsEntryPublishHandler.ts` +- ✅ `handler/actions/UnpublishHandlerAction.ts` → `api-headless-cms/src/features/scheduler/handlers/CmsEntryUnpublishHandler.ts` +- ✅ `graphql/` → `api-headless-cms/src/features/scheduler/graphql/` -4. **Consistency**: Matches ProcessRecords feature pattern - - Same DI patterns throughout codebase - - Same abstraction/implementation split +### KEEP and Update +- ✅ `ProcessRecordsUseCase.ts` → rename to `ExecuteScheduledActionUseCase.ts` (generic orchestration) +- ✅ `scheduler/model.ts` → update to generic `webinyScheduledAction` model -5. **Separation of Concerns**: Clear abstraction boundaries - - Use cases don't know about AWS EventBridge - - Actions don't know about each other - - Service object is just a facade +### NO LONGER NEEDED +- ❌ `scheduler/ScheduleFetcher.ts` - Replaced by `GetScheduledActionUseCase` and `ListScheduledActionsUseCase` -6. **Type Safety**: TypeScript enforces dependency contracts - - Container validates dependencies at registration - - Compile-time checking of dependency types +## Benefits of Generic Architecture -7. **Maintainability**: Dependencies declared explicitly - - Easy to see what each component needs - - Refactoring dependencies is straightforward +1. **Reusable Across Apps** + - CMS can schedule entry publish/unpublish + - Mailer can schedule email sending + - Website Builder can schedule page deletion + - Any app can schedule any action -8. **Reusability**: Components can be reused in different contexts - - Use cases can be composed differently - - Actions can be used outside scheduler context +2. **Clear Separation of Concerns** + - Scheduling logic (generic) vs. Execution logic (app-specific) + - No confusion between two action patterns + - Single responsibility per class -9. **No God Objects**: No convenience wrappers that grow over time - - Each use case is resolved individually - - GraphQL resolvers explicitly choose which use case to call - - Prevents accidental dependencies between operations +3. **No Immediate Execution Confusion** + - Scheduler is ONLY for future actions + - Immediate actions use direct use cases + - Clear boundary -## Challenges and Solutions +4. **Smart Reschedule** + - No separate `reschedule()` method + - `schedule()` detects existing and updates automatically + - Less API surface area -### Challenge 1: Model-Specific Context -**Problem**: Scheduler needs model context (targetModel varies per request, schedulerModel is constant) +5. **Extensible** + - Apps register handlers like event handlers + - No core code changes needed for new actions + - Type-safe with constants -**Solution**: Pass `targetModel` as method parameter, inject `schedulerModel` as instance -- `targetModel` → method parameter (varies per request) -- `schedulerModel` → registered as instance (constant) +6. **No God Objects** + - No `context.cms.scheduler` + - Direct container resolution + - Explicit dependencies -### Challenge 2: CMS Use Cases Not Abstracted -**Problem**: Actions depend on CMS methods like `getEntryById`, `publishEntry`, etc. +7. **Consistent Patterns** + - Same as EventPublisher/EventHandler + - Same as ProcessRecords feature + - Developers know the pattern -**Solution**: -- **Short-term**: Leave as TODOs in dependencies, inject CMS context -- **Long-term**: Create proper use case abstractions for all CMS operations +## Migration Strategy -### Challenge 3: External Dependencies (AWS Client, Manifest Config) -**Problem**: EventBridgeSchedulerService depends on AWS client factory and manifest config +### Option A: Keep CMS-Specific Package, Extract Core -**Solution**: Register as instances during context setup: -```typescript -container.registerInstance(SchedulerServiceConfig, manifestConfig); -container.registerFactory(SchedulerServiceClientFactory, clientFactory); ``` - -### Challenge 4: Identity Provider -**Problem**: Actions need current user identity (runtime value) - -**Solution**: Register identity factory in parent container: -```typescript -container.registerFactory(IdentityProvider, () => security.getIdentity()); +packages/ +├── scheduler-core/ # Generic scheduler (new) +│ └── src/features/Scheduler/ +│ +└── api-headless-cms-scheduler/ # CMS integration (existing) + └── src/ + ├── handlers/ # CMS handlers + └── graphql/ # CMS GraphQL ``` -### Challenge 5: Model Validation -**Problem**: Need to validate `targetModel.isPrivate` before operations +**Pros:** +- No breaking changes for consumers +- Clear separation +- Can version independently -**Solution**: Use decorator pattern (transparent to consumers): -```typescript -container.registerDecorator(ValidateNotPrivateModel); -``` +**Cons:** +- Two packages to maintain +- Import paths change -### Challenge 6: Context God Object -**Problem**: Don't want to add `context.cms.scheduler` +### Option B: Rename Package to Generic -**Solution**: GraphQL resolvers resolve use cases directly from `context.container` +``` +packages/ +└── api-scheduler/ # Generic (renamed) + └── src/ + └── features/ + └── Scheduler/ # Core +``` -## Migration Sequence +CMS handlers move to: +``` +packages/api-headless-cms/src/features/scheduler/ +``` -### Step 1: Create Abstractions (No Breaking Changes) -- Create `features/Scheduler/abstractions.ts` -- Define all abstractions -- No existing code changes +**Pros:** +- Single package +- Clear that it's generic +- CMS handlers where they belong -### Step 2: Refactor SchedulerService (Independent) -- Create SchedulerService abstraction and implementation -- Register in feature -- Test independently +**Cons:** +- Breaking change (package rename) +- Migration effort for consumers -### Step 3: Refactor ScheduleFetcher (Minimal Dependencies) -- Create ScheduleFetcherUseCase -- Leave CMS dependencies as TODOs -- Register in feature +### Recommendation: **Option A** (Extract Core) -### Step 4: Refactor Actions (Complex Dependencies) -- Create PublishScheduleAction with DI -- Create UnpublishScheduleAction with DI -- Leave CMS use cases as TODOs -- Register in feature +Start with Option A to avoid breaking changes. Later, if we want to consolidate, we can deprecate the old package. -### Step 5: Refactor ScheduleExecutor (Uses Actions + Fetcher) -- Create ScheduleExecutorUseCase -- Inject actions and fetcher -- Register in feature +## Data Model Changes -### Step 6: Refactor Scheduler (Uses Executor + Fetcher) -- Create SchedulerUseCase -- Inject executor and fetcher -- Register in feature +### Current Model: `webinyCmsSchedule` -### Step 7: Create Factory (Uses Container) -- Create SchedulerFactory -- Implement child container pattern -- Register in feature +```typescript +{ + targetId: string; // Entry ID with version + targetModelId: string; // CMS model ID + scheduledBy: Identity; + scheduledOn: Date; + type: "publish" | "unpublish"; + title: string; // Entry title + error?: string; +} +``` -### Step 8: Update Context Integration -- Update `context.ts` to use factory -- Remove old `createScheduler` factory -- Test end-to-end +### New Model: `webinyScheduledAction` -### Step 9: Clean Up -- Remove old files from `scheduler/` directory -- Update imports throughout codebase -- Update tests +```typescript +{ + id: string; // Unique schedule ID + actionId: string; // "Cms/Entry/Publish", "Mailer/Email/Send" + targetId: string; // Resource identifier (entry ID, email ID, etc.) + scheduledBy: Identity; + scheduledOn: Date; + payload?: any; // Action-specific data (model, email data, etc.) + error?: string; // Execution error +} +``` -## Testing Strategy +## Success Criteria -### Unit Tests -- Test each use case in isolation with mocked dependencies -- Test actions with mocked services -- Test factory with mocked container +- ✅ Generic scheduler works for any app (CMS, Mailer, etc.) +- ✅ No CMS-specific logic in core scheduler +- ✅ Handlers registered like event handlers +- ✅ Action IDs are hierarchical strings +- ✅ Each use case has single responsibility +- ✅ No god objects (`context.cms.scheduler` removed) +- ✅ GraphQL resolvers use `context.container` directly +- ✅ Tests pass with mocked dependencies +- ✅ New actions added via handler registration only +- ✅ Code follows EventPublisher/EventHandler pattern +- ✅ CMS scheduling works identically to before +- ✅ Can schedule non-CMS actions (email, page delete, etc.) +- ✅ No confusion between scheduling and execution logic +- ✅ No separate `reschedule()` method needed +- ✅ Immediate execution bypasses scheduler -### Integration Tests -- Test scheduler creation flow -- Test schedule/cancel operations end-to-end -- Test with real AWS EventBridge (optional) +## Future Enhancements -### Migration Tests -- Ensure behavior identical before/after migration -- Test all schedule types (publish, unpublish, immediate, past date, future date) -- Test error scenarios +1. **Typed Action Schemas** + - Define TypeScript types for each action's payload + - Validate payloads at registration time -## Rollback Plan +2. **Recurring Schedules** + - Support cron expressions + - Repeat actions on schedule -If migration causes issues: +3. **Action Groups** + - Schedule multiple actions together + - All-or-nothing execution -1. **Keep old code**: Don't delete old files until migration complete -2. **Feature flag**: Use environment variable to toggle old/new implementation -3. **Gradual rollout**: Test in dev → staging → production +4. **Priority Queues** + - High-priority actions execute first + - Background vs. urgent actions -## Timeline Estimate +5. **Retry Logic** + - Automatic retry on failure + - Exponential backoff -- **Phase 1** (Abstractions): 2-3 hours -- **Phase 2** (Actions refactor): 4-6 hours -- **Phase 3** (Use cases): 3-4 hours -- **Phase 4** (Factory): 3-4 hours -- **Phase 5** (Feature registration): 1-2 hours -- **Phase 6** (SchedulerService): 2-3 hours -- **Phase 7** (Context integration): 2-3 hours -- **Testing**: 4-6 hours +6. **Audit Trail** + - Log all schedule creations/executions + - Track who scheduled what and when -**Total**: ~20-30 hours +7. **UI Components** + - Admin UI to view/manage schedules + - Calendar view of upcoming actions -## Open Questions +## Example: Adding Mailer Support -1. **CMS Use Case Abstractions**: Should we create them now or leave as TODOs? - - **Recommendation**: Leave as TODOs, create separately when migrating CMS core +To demonstrate extensibility, here's how you'd add email scheduling: -2. **targetModel Parameter**: Should it be first or last parameter in execute() methods? - - **Recommendation**: First parameter - it's the primary context +**Step 1: Define Action ID** +```typescript +// packages/api-mailer/src/scheduler/constants.ts +export const MAILER_EMAIL_SEND_ACTION = "Mailer/Email/Send"; +``` -3. **Identity Management**: Should identity be injected or fetched on-demand? - - **Recommendation**: Register factory in parent container for on-demand access +**Step 2: Create Handler** +```typescript +// packages/api-mailer/src/scheduler/handlers/MailerEmailSendHandler.ts +class MailerEmailSendHandlerImpl implements ScheduledActionHandler.Interface { + constructor(private emailService: EmailService) {} -4. **EventBridge Service Layer**: Should SchedulerService stay in `service/` or move to `features/`? - - **Recommendation**: Move to `features/Scheduler/` and rename to `EventBridgeSchedulerService` for clarity + canHandle(actionId: string): boolean { + return actionId === MAILER_EMAIL_SEND_ACTION; + } -5. **GraphQL Resolver Pattern**: Resolve use case every request or cache? - - **Recommendation**: Resolve every request - containers are fast and we avoid memory leaks + async handle(action: IScheduledAction): Promise { + // Payload contains email data + const { to, subject, body, from } = action.payload; -6. **Validation Decorator Scope**: Apply to all use cases or just commands? - - **Recommendation**: Just commands (Schedule, Cancel) - queries don't mutate so don't need validation + await this.emailService.send({ + to, + subject, + body, + from + }); + } +} -## Success Criteria +export const MailerEmailSendHandler = ScheduledActionHandler.createImplementation({ + implementation: MailerEmailSendHandlerImpl, + dependencies: [EmailService] +}); +``` -- ✅ All scheduler operations work identically to before -- ✅ No manual instantiation (everything via DI container) -- ✅ All dependencies injected via DI container -- ✅ Tests pass with mocked dependencies -- ✅ New schedule types can be added via registration only -- ✅ Code follows same patterns as ProcessRecords feature -- ✅ Each use case has single responsibility with one `execute()` method -- ✅ No convenience wrapper/god objects -- ✅ No factory pattern needed -- ✅ No child containers needed -- ✅ GraphQL resolvers explicitly resolve individual use cases from `context.container` -- ✅ No `context.cms.scheduler` - avoiding god object pattern -- ✅ `targetModel` passed as method parameter -- ✅ Validation transparent via decorator +**Step 3: Register Handler** +```typescript +// packages/api-mailer/src/scheduler/feature.ts +export const MailerSchedulerHandlersFeature = createFeature({ + name: "MailerSchedulerHandlers", + register(container) { + container.register(MailerEmailSendHandler); + } +}); -## Future Enhancements +// In context.ts +MailerSchedulerHandlersFeature.register(context.container); +``` -After migration: +**Step 4: Use in GraphQL/API** +```typescript +const scheduleEmail = async (_, args, context) => { + const scheduleUseCase = context.container.resolve(ScheduleActionUseCase); + + return scheduleUseCase.execute( + MAILER_EMAIL_SEND_ACTION, + `email-${generateId()}`, + { scheduleOn: args.sendAt }, + { + to: args.to, + subject: args.subject, + body: args.body, + from: args.from + } + ); +}; +``` -1. **CMS Use Case Abstractions**: Create proper abstractions for all CMS operations -2. **Event Publishing**: Add domain events (ScheduleCreated, ScheduleCanceled, etc.) -3. **Error Handling**: Use Result pattern instead of throwing exceptions -4. **Validation**: Move validation to dedicated use cases/validators -5. **Decorators**: Add decorators for access control, logging, etc. -6. **Composite Actions**: Support complex schedule types with composite pattern +**Done!** No changes to core scheduler needed. ## References -- ProcessRecords feature implementation: `src/features/ProcessRecords/` -- DI Container documentation: `ai-context/di-container.md` -- Backend Developer Guide: `ai-context/backend-developer-guide.md` +- ProcessRecords feature: `src/features/ProcessRecords/` +- EventPublisher pattern: `@webiny/event-publisher` +- DI Container docs: `ai-context/di-container.md` - Current architecture: `ARCHITECTURE.md` diff --git a/packages/api-headless-cms-scheduler/src/hooks/index.ts b/packages/api-headless-cms-scheduler/src/hooks/index.ts index 805f065c9e1..2b345efb43b 100644 --- a/packages/api-headless-cms-scheduler/src/hooks/index.ts +++ b/packages/api-headless-cms-scheduler/src/hooks/index.ts @@ -1,3 +1,4 @@ +// @ts-nocheck TODO migrate lifecycle events /** * We are attaching hooks so we can make sure that the scheduled jobs are removed. * This is due the possibility that the user publishes/unpublishes/deletes entry which is scheduled. From 02c5238bf1f927064dd5708932e7ba1c40f261f4 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 15:14:09 +0100 Subject: [PATCH 34/71] wip: base api-scheduler setup --- packages/api-scheduler/.babelrc.js | 3 + packages/api-scheduler/ci.config.json | 6 + .../api-scheduler/jest-dynalite-config.cjs | 3 + packages/api-scheduler/package.json | 42 +++++ packages/api-scheduler/tsconfig.build.json | 178 ++++++++++++++++++ packages/api-scheduler/tsconfig.json | 178 ++++++++++++++++++ packages/api-scheduler/vitest.config.ts | 14 ++ packages/api-scheduler/webiny.config.js | 8 + 8 files changed, 432 insertions(+) create mode 100644 packages/api-scheduler/.babelrc.js create mode 100644 packages/api-scheduler/ci.config.json create mode 100644 packages/api-scheduler/jest-dynalite-config.cjs create mode 100644 packages/api-scheduler/package.json create mode 100644 packages/api-scheduler/tsconfig.build.json create mode 100644 packages/api-scheduler/tsconfig.json create mode 100644 packages/api-scheduler/vitest.config.ts create mode 100644 packages/api-scheduler/webiny.config.js diff --git a/packages/api-scheduler/.babelrc.js b/packages/api-scheduler/.babelrc.js new file mode 100644 index 00000000000..421716f2aab --- /dev/null +++ b/packages/api-scheduler/.babelrc.js @@ -0,0 +1,3 @@ +module.exports = require("@webiny/build-tools").createBabelConfigForNode({ + path: __dirname +}); diff --git a/packages/api-scheduler/ci.config.json b/packages/api-scheduler/ci.config.json new file mode 100644 index 00000000000..5a6d5348b40 --- /dev/null +++ b/packages/api-scheduler/ci.config.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../.github/workflows/ci.config.schema.json", + "vitest": { + "storageOps": ["ddb", "ddb-os,ddb"] + } +} diff --git a/packages/api-scheduler/jest-dynalite-config.cjs b/packages/api-scheduler/jest-dynalite-config.cjs new file mode 100644 index 00000000000..5644a843795 --- /dev/null +++ b/packages/api-scheduler/jest-dynalite-config.cjs @@ -0,0 +1,3 @@ +const { createDynaliteTables } = require("../../dynalite.cjs"); + +module.exports = createDynaliteTables(); diff --git a/packages/api-scheduler/package.json b/packages/api-scheduler/package.json new file mode 100644 index 00000000000..62fddbc1672 --- /dev/null +++ b/packages/api-scheduler/package.json @@ -0,0 +1,42 @@ +{ + "name": "@webiny/api-scheduler", + "version": "0.0.0", + "type": "module", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git", + "directory": "packages/api-scheduler" + }, + "keywords": [ + "scheduler:base" + ], + "author": "Webiny Ltd", + "description": "A generic action scheduler.", + "license": "MIT", + "dependencies": { + "@webiny/api": "0.0.0", + "@webiny/api-headless-cms": "0.0.0", + "@webiny/error": "0.0.0", + "@webiny/feature": "0.0.0", + "@webiny/handler-graphql": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/pubsub": "0.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@webiny/build-tools": "0.0.0", + "@webiny/db-dynamodb": "0.0.0", + "@webiny/handler-aws": "0.0.0", + "@webiny/project-utils": "0.0.0", + "jest-dynalite": "^3.6.1", + "rimraf": "^6.0.1", + "typescript": "5.9.3", + "vitest": "^3.2.4" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "gitHead": "8476da73b653c89cc1474d968baf55c1b0ae0e5f" +} diff --git a/packages/api-scheduler/tsconfig.build.json b/packages/api-scheduler/tsconfig.build.json new file mode 100644 index 00000000000..c79ef6f6691 --- /dev/null +++ b/packages/api-scheduler/tsconfig.build.json @@ -0,0 +1,178 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../api/tsconfig.build.json" }, + { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../error/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, + { "path": "../handler-graphql/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../api-core/tsconfig.build.json" }, + { "path": "../db-dynamodb/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, + { "path": "../handler-aws/tsconfig.build.json" }, + { "path": "../handler-db/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], + "@webiny/api-core/features/EventPublisher": [ + "../api-core/src/features/eventPublisher/index.js" + ], + "@webiny/api-core/features/IdentityContext": [ + "../api-core/src/features/security/IdentityContext/index.js" + ], + "@webiny/api-core/features/WcpContext": ["../api-core/src/features/wcp/WcpContext/index.js"], + "@webiny/api-core/features/settings": ["../api-core/src/features/settings/index.js"], + "@webiny/api-core/features/GetSettings": [ + "../api-core/src/features/settings/GetSettings/index.js" + ], + "@webiny/api-core/features/UpdateSettings": [ + "../api-core/src/features/settings/UpdateSettings/index.js" + ], + "@webiny/api-core/features/DeleteSettings": [ + "../api-core/src/features/settings/DeleteSettings/index.js" + ], + "@webiny/api-core/features/CreateApiKey": [ + "../api-core/src/features/security/apiKeys/CreateApiKey/index.js" + ], + "@webiny/api-core/features/DeleteApiKey": [ + "../api-core/src/features/security/apiKeys/DeleteApiKey/index.js" + ], + "@webiny/api-core/features/GetApiKey": [ + "../api-core/src/features/security/apiKeys/GetApiKey/index.js" + ], + "@webiny/api-core/features/GetApiKeyByToken": [ + "../api-core/src/features/security/apiKeys/GetApiKeyByToken/index.js" + ], + "@webiny/api-core/features/ListApiKeys": [ + "../api-core/src/features/security/apiKeys/ListApiKeys/index.js" + ], + "@webiny/api-core/features/UpdateApiKey": [ + "../api-core/src/features/security/apiKeys/UpdateApiKey/index.js" + ], + "@webiny/api-core/features/AuthenticationContext": [ + "../api-core/src/features/security/authentication/AuthenticationContext/index.js" + ], + "@webiny/api-core/features/Authenticator": [ + "../api-core/src/features/security/authentication/Authenticator/index.js" + ], + "@webiny/api-core/features/AuthorizationContext": [ + "../api-core/src/features/security/authorization/AuthorizationContext/index.js" + ], + "@webiny/api-core/features/Authorizer": [ + "../api-core/src/features/security/authorization/Authorizer/index.js" + ], + "@webiny/api-core/features/CreateGroup": [ + "../api-core/src/features/security/groups/CreateGroup/index.js" + ], + "@webiny/api-core/features/DeleteGroup": [ + "../api-core/src/features/security/groups/DeleteGroup/index.js" + ], + "@webiny/api-core/features/GetGroup": [ + "../api-core/src/features/security/groups/GetGroup/index.js" + ], + "@webiny/api-core/features/ListGroups": [ + "../api-core/src/features/security/groups/ListGroups/index.js" + ], + "@webiny/api-core/features/UpdateGroup": [ + "../api-core/src/features/security/groups/UpdateGroup/index.js" + ], + "@webiny/api-core/features/CreateTeam": [ + "../api-core/src/features/security/teams/CreateTeam/index.js" + ], + "@webiny/api-core/features/DeleteTeam": [ + "../api-core/src/features/security/teams/DeleteTeam/index.js" + ], + "@webiny/api-core/features/GetTeam": [ + "../api-core/src/features/security/teams/GetTeam/index.js" + ], + "@webiny/api-core/features/ListTeams": [ + "../api-core/src/features/security/teams/ListTeams/index.js" + ], + "@webiny/api-core/features/UpdateTeam": [ + "../api-core/src/features/security/teams/UpdateTeam/index.js" + ], + "@webiny/api-core/features/CreateTenantLinks": [ + "../api-core/src/features/security/tenantLinks/CreateTenantLinks/index.js" + ], + "@webiny/api-core/features/DeleteTenantLinks": [ + "../api-core/src/features/security/tenantLinks/DeleteTenantLinks/index.js" + ], + "@webiny/api-core/features/GetTenantLinkByIdentity": [ + "../api-core/src/features/security/tenantLinks/GetTenantLinkByIdentity/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByIdentity": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByIdentity/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByTenant": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByTenant/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByType": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByType/index.js" + ], + "@webiny/api-core/features/UpdateTenantLinks": [ + "../api-core/src/features/security/tenantLinks/UpdateTenantLinks/index.js" + ], + "@webiny/api-core/features/InstallSystem": [ + "../api-core/src/features/system/InstallSystem/index.js" + ], + "@webiny/api-core/features/InstallTenant": [ + "../api-core/src/features/tenancy/InstallTenant/index.js" + ], + "@webiny/api-core/features/TenantContext": [ + "../api-core/src/features/tenancy/TenantContext/index.js" + ], + "@webiny/api-core/features/CreateUser": [ + "../api-core/src/features/users/CreateUser/index.js" + ], + "@webiny/api-core/features/DeleteUser": [ + "../api-core/src/features/users/DeleteUser/index.js" + ], + "@webiny/api-core/features/UpdateUser": [ + "../api-core/src/features/users/UpdateUser/index.js" + ], + "@webiny/api-core/features/GetUser": ["../api-core/src/features/users/GetUser/index.js"], + "@webiny/api-core/features/ListUsers": ["../api-core/src/features/users/ListUsers/index.js"], + "@webiny/api-core/features/ListUserTeams": [ + "../api-core/src/features/users/ListUserTeams/index.js" + ], + "@webiny/api-core/features/ExternalIdpUserSync": [ + "../api-core/src/features/users/ExternalIdpUserSync/index.js" + ], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], + "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-db/*": ["../handler-db/src/*"], + "@webiny/handler-db": ["../handler-db/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-scheduler/tsconfig.json b/packages/api-scheduler/tsconfig.json new file mode 100644 index 00000000000..f8740723dd5 --- /dev/null +++ b/packages/api-scheduler/tsconfig.json @@ -0,0 +1,178 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../api" }, + { "path": "../api-headless-cms" }, + { "path": "../error" }, + { "path": "../feature" }, + { "path": "../handler-graphql" }, + { "path": "../plugins" }, + { "path": "../pubsub" }, + { "path": "../api-core" }, + { "path": "../db-dynamodb" }, + { "path": "../handler" }, + { "path": "../handler-aws" }, + { "path": "../handler-db" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], + "@webiny/api-core/features/EventPublisher": [ + "../api-core/src/features/eventPublisher/index.js" + ], + "@webiny/api-core/features/IdentityContext": [ + "../api-core/src/features/security/IdentityContext/index.js" + ], + "@webiny/api-core/features/WcpContext": ["../api-core/src/features/wcp/WcpContext/index.js"], + "@webiny/api-core/features/settings": ["../api-core/src/features/settings/index.js"], + "@webiny/api-core/features/GetSettings": [ + "../api-core/src/features/settings/GetSettings/index.js" + ], + "@webiny/api-core/features/UpdateSettings": [ + "../api-core/src/features/settings/UpdateSettings/index.js" + ], + "@webiny/api-core/features/DeleteSettings": [ + "../api-core/src/features/settings/DeleteSettings/index.js" + ], + "@webiny/api-core/features/CreateApiKey": [ + "../api-core/src/features/security/apiKeys/CreateApiKey/index.js" + ], + "@webiny/api-core/features/DeleteApiKey": [ + "../api-core/src/features/security/apiKeys/DeleteApiKey/index.js" + ], + "@webiny/api-core/features/GetApiKey": [ + "../api-core/src/features/security/apiKeys/GetApiKey/index.js" + ], + "@webiny/api-core/features/GetApiKeyByToken": [ + "../api-core/src/features/security/apiKeys/GetApiKeyByToken/index.js" + ], + "@webiny/api-core/features/ListApiKeys": [ + "../api-core/src/features/security/apiKeys/ListApiKeys/index.js" + ], + "@webiny/api-core/features/UpdateApiKey": [ + "../api-core/src/features/security/apiKeys/UpdateApiKey/index.js" + ], + "@webiny/api-core/features/AuthenticationContext": [ + "../api-core/src/features/security/authentication/AuthenticationContext/index.js" + ], + "@webiny/api-core/features/Authenticator": [ + "../api-core/src/features/security/authentication/Authenticator/index.js" + ], + "@webiny/api-core/features/AuthorizationContext": [ + "../api-core/src/features/security/authorization/AuthorizationContext/index.js" + ], + "@webiny/api-core/features/Authorizer": [ + "../api-core/src/features/security/authorization/Authorizer/index.js" + ], + "@webiny/api-core/features/CreateGroup": [ + "../api-core/src/features/security/groups/CreateGroup/index.js" + ], + "@webiny/api-core/features/DeleteGroup": [ + "../api-core/src/features/security/groups/DeleteGroup/index.js" + ], + "@webiny/api-core/features/GetGroup": [ + "../api-core/src/features/security/groups/GetGroup/index.js" + ], + "@webiny/api-core/features/ListGroups": [ + "../api-core/src/features/security/groups/ListGroups/index.js" + ], + "@webiny/api-core/features/UpdateGroup": [ + "../api-core/src/features/security/groups/UpdateGroup/index.js" + ], + "@webiny/api-core/features/CreateTeam": [ + "../api-core/src/features/security/teams/CreateTeam/index.js" + ], + "@webiny/api-core/features/DeleteTeam": [ + "../api-core/src/features/security/teams/DeleteTeam/index.js" + ], + "@webiny/api-core/features/GetTeam": [ + "../api-core/src/features/security/teams/GetTeam/index.js" + ], + "@webiny/api-core/features/ListTeams": [ + "../api-core/src/features/security/teams/ListTeams/index.js" + ], + "@webiny/api-core/features/UpdateTeam": [ + "../api-core/src/features/security/teams/UpdateTeam/index.js" + ], + "@webiny/api-core/features/CreateTenantLinks": [ + "../api-core/src/features/security/tenantLinks/CreateTenantLinks/index.js" + ], + "@webiny/api-core/features/DeleteTenantLinks": [ + "../api-core/src/features/security/tenantLinks/DeleteTenantLinks/index.js" + ], + "@webiny/api-core/features/GetTenantLinkByIdentity": [ + "../api-core/src/features/security/tenantLinks/GetTenantLinkByIdentity/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByIdentity": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByIdentity/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByTenant": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByTenant/index.js" + ], + "@webiny/api-core/features/ListTenantLinksByType": [ + "../api-core/src/features/security/tenantLinks/ListTenantLinksByType/index.js" + ], + "@webiny/api-core/features/UpdateTenantLinks": [ + "../api-core/src/features/security/tenantLinks/UpdateTenantLinks/index.js" + ], + "@webiny/api-core/features/InstallSystem": [ + "../api-core/src/features/system/InstallSystem/index.js" + ], + "@webiny/api-core/features/InstallTenant": [ + "../api-core/src/features/tenancy/InstallTenant/index.js" + ], + "@webiny/api-core/features/TenantContext": [ + "../api-core/src/features/tenancy/TenantContext/index.js" + ], + "@webiny/api-core/features/CreateUser": [ + "../api-core/src/features/users/CreateUser/index.js" + ], + "@webiny/api-core/features/DeleteUser": [ + "../api-core/src/features/users/DeleteUser/index.js" + ], + "@webiny/api-core/features/UpdateUser": [ + "../api-core/src/features/users/UpdateUser/index.js" + ], + "@webiny/api-core/features/GetUser": ["../api-core/src/features/users/GetUser/index.js"], + "@webiny/api-core/features/ListUsers": ["../api-core/src/features/users/ListUsers/index.js"], + "@webiny/api-core/features/ListUserTeams": [ + "../api-core/src/features/users/ListUserTeams/index.js" + ], + "@webiny/api-core/features/ExternalIdpUserSync": [ + "../api-core/src/features/users/ExternalIdpUserSync/index.js" + ], + "@webiny/api-core/*": ["../api-core/src/*"], + "@webiny/api-core": ["../api-core/src"], + "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], + "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], + "@webiny/handler-aws/*": ["../handler-aws/src/*"], + "@webiny/handler-aws": ["../handler-aws/src"], + "@webiny/handler-db/*": ["../handler-db/src/*"], + "@webiny/handler-db": ["../handler-db/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/api-scheduler/vitest.config.ts b/packages/api-scheduler/vitest.config.ts new file mode 100644 index 00000000000..ff7f36e0e79 --- /dev/null +++ b/packages/api-scheduler/vitest.config.ts @@ -0,0 +1,14 @@ +import { createTestConfig } from "../../testing"; + +export default async () => { + const { getPresets } = await import("@webiny/project-utils/testing/presets/index.js"); + const presets = await getPresets( + ["@webiny/api-headless-cms", "storage-operations"], + ["@webiny/api-core", "storage-operations"] + ); + + return createTestConfig({ + path: import.meta.dirname, + presets + }); +}; diff --git a/packages/api-scheduler/webiny.config.js b/packages/api-scheduler/webiny.config.js new file mode 100644 index 00000000000..4f5e3db04b9 --- /dev/null +++ b/packages/api-scheduler/webiny.config.js @@ -0,0 +1,8 @@ +import { createWatchPackage, createBuildPackage } from "@webiny/build-tools"; + +export default { + commands: { + build: createBuildPackage({ cwd: import.meta.dirname }), + watch: createWatchPackage({ cwd: import.meta.dirname }) + } +}; From a87261f55267a87a9696bd1a665ad80f0229f090 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 15:35:32 +0100 Subject: [PATCH 35/71] wip: base api-scheduler setup --- .../SCHEDULER.md | 112 ++++++++++++------ 1 file changed, 75 insertions(+), 37 deletions(-) rename packages/{api-headless-cms-scheduler => api-scheduler}/SCHEDULER.md (92%) diff --git a/packages/api-headless-cms-scheduler/SCHEDULER.md b/packages/api-scheduler/SCHEDULER.md similarity index 92% rename from packages/api-headless-cms-scheduler/SCHEDULER.md rename to packages/api-scheduler/SCHEDULER.md index 2a7afd79f0b..e0f404b86fd 100644 --- a/packages/api-headless-cms-scheduler/SCHEDULER.md +++ b/packages/api-scheduler/SCHEDULER.md @@ -66,14 +66,20 @@ The scheduler becomes a **generic action scheduler** similar to how EventPublish ### Key Design Decisions -1. **Action Identifier**: Hierarchical string format `{namespace}/{entity}/{operation}` - - Examples: `"Cms/Entry/Publish"`, `"Mailer/Email/Send"`, `"Website/Page/Delete"` +1. **Action Identifier**: Separate `namespace` and `actionType` fields + - **namespace**: Identifies the resource scope (e.g., `"Cms/Entry/Article"`, `"Mailer/Email"`) + - **actionType**: Identifies the operation (e.g., `"Publish"`, `"Unpublish"`, `"Send"`) + - **Why separate**: Enables efficient queries by namespace (e.g., "all scheduled actions for Article model") + - Examples: + - `namespace: "Cms/Entry/Article", actionType: "Publish"` + - `namespace: "Cms/Entry/Product", actionType: "Unpublish"` + - `namespace: "Mailer/Email", actionType: "Send"` 2. **No CMS-specific logic in core** - All CMS logic moves to handlers 3. **Apps register handlers** - Just like event handlers -4. **Method parameter pattern** - `actionId` passed as parameter (varies per request) +4. **Method parameter pattern** - `namespace` and `actionType` passed as parameters (vary per request) 5. **No god objects** - No `context.cms.scheduler`, use `context.container` to register and resolve implementations @@ -317,7 +323,8 @@ return scheduleUseCase.execute( */ export interface IScheduledAction { id: string; - actionId: string; // "Cms/Entry/Publish", "Mailer/Email/Send" + namespace: string; // Resource scope: "Cms/Entry/Article", "Mailer/Email" + actionType: string; // Operation: "Publish", "Unpublish", "Send", "Delete" targetId: string; // Resource identifier (entry ID, email ID, etc.) scheduledBy: Identity; scheduledOn: Date; @@ -337,9 +344,10 @@ export interface ISchedulerInput { */ export interface ISchedulerListParams { where?: { - actionId?: string; - targetId?: string; - scheduledBy?: string; + namespace?: string; // Filter by resource scope + actionType?: string; // Filter by operation type + targetId?: string; // Filter by specific resource + scheduledBy?: string; // Filter by who scheduled scheduledOn_gte?: string; scheduledOn_lte?: string; }; @@ -370,8 +378,11 @@ export interface ISchedulerListResponse { export interface IScheduledActionHandler { /** * Determines if this handler can handle the given action + * + * @param namespace - Resource scope (e.g., "Cms/Entry/Article") + * @param actionType - Operation type (e.g., "Publish") */ - canHandle(actionId: string): boolean; + canHandle(namespace: string, actionType: string): boolean; /** * Executes the scheduled action @@ -398,7 +409,8 @@ export namespace ScheduledActionHandler { */ export interface IScheduleActionUseCase { execute( - actionId: string, + namespace: string, + actionType: string, targetId: string, input: ISchedulerInput, payload?: any @@ -1096,12 +1108,13 @@ import { ScheduledActionHandler } from "@webiny/api-scheduler"; import type { IScheduledAction } from "@webiny/api-scheduler"; import { PublishEntryUseCase } from "~/features/contentEntry/PublishEntry/index.js"; import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; -import { CMS_ENTRY_PUBLISH_ACTION } from "../constants.js"; +import { CMS_ACTION_TYPES } from "../constants.js"; /** * Handles scheduled publish actions for CMS entries * - * Action ID: "Cms/Entry/Publish" + * Namespace: "Cms/Entry/{modelId}" (e.g., "Cms/Entry/Article") + * Action Type: "Publish" */ class CmsEntryPublishHandlerImpl implements ScheduledActionHandler.Interface { constructor( @@ -1109,14 +1122,17 @@ class CmsEntryPublishHandlerImpl implements ScheduledActionHandler.Interface { private getModel: GetModelUseCase.Interface ) {} - canHandle(actionId: string): boolean { - return actionId === CMS_ENTRY_PUBLISH_ACTION; + canHandle(namespace: string, actionType: string): boolean { + return namespace.startsWith("Cms/Entry/") && actionType === CMS_ACTION_TYPES.PUBLISH; } async handle(action: IScheduledAction): Promise { - // Parse targetId to extract model and entry - // Format: "modelId#version" e.g., "product#0001" - const [modelId, version] = action.targetId.split("#"); + // Extract model ID from namespace: "Cms/Entry/Article" -> "Article" + const modelId = action.namespace.split("/").pop()!; + + // Parse targetId to extract entry version + // Format: "entryId#version" e.g., "article-1#0001" + const [entryId, version] = action.targetId.split("#"); // Get model (could be cached in payload for optimization) const model = action.payload?.model || await this.getModel.execute(modelId); @@ -1146,12 +1162,13 @@ import { ScheduledActionHandler } from "@webiny/api-scheduler"; import type { IScheduledAction } from "@webiny/api-scheduler"; import { UnpublishEntryUseCase } from "~/features/contentEntry/UnpublishEntry/index.js"; import { GetModelUseCase } from "~/features/contentModel/GetModel/index.js"; -import { CMS_ENTRY_UNPUBLISH_ACTION } from "../constants.js"; +import { CMS_ACTION_TYPES } from "../constants.js"; /** * Handles scheduled unpublish actions for CMS entries * - * Action ID: "Cms/Entry/Unpublish" + * Namespace: "Cms/Entry/{modelId}" (e.g., "Cms/Entry/Article") + * Action Type: "Unpublish" */ class CmsEntryUnpublishHandlerImpl implements ScheduledActionHandler.Interface { constructor( @@ -1159,12 +1176,13 @@ class CmsEntryUnpublishHandlerImpl implements ScheduledActionHandler.Interface { private getModel: GetModelUseCase.Interface ) {} - canHandle(actionId: string): boolean { - return actionId === CMS_ENTRY_UNPUBLISH_ACTION; + canHandle(namespace: string, actionType: string): boolean { + return namespace.startsWith("Cms/Entry/") && actionType === CMS_ACTION_TYPES.UNPUBLISH; } async handle(action: IScheduledAction): Promise { - const [modelId, version] = action.targetId.split("#"); + const modelId = action.namespace.split("/").pop()!; + const [entryId, version] = action.targetId.split("#"); const model = action.payload?.model || await this.getModel.execute(modelId); const result = await this.unpublishEntry.execute(model, action.targetId); @@ -1188,10 +1206,21 @@ export const CmsEntryUnpublishHandler = ScheduledActionHandler.createImplementat ```typescript /** - * CMS Scheduled Action IDs + * CMS Action Types + */ +export const CMS_ACTION_TYPES = { + PUBLISH: "Publish", + UNPUBLISH: "Unpublish", + DELETE: "Delete" +} as const; + +/** + * Helper to create CMS Entry namespace for a specific model + * + * @param modelId - Content model ID (e.g., "Article", "Product") + * @returns Namespace string (e.g., "Cms/Entry/Article") */ -export const CMS_ENTRY_PUBLISH_ACTION = "Cms/Entry/Publish"; -export const CMS_ENTRY_UNPUBLISH_ACTION = "Cms/Entry/Unpublish"; +export const getCmsEntryNamespace = (modelId: string) => `Cms/Entry/${modelId}`; ``` **File**: `packages/api-headless-cms/src/features/scheduler/feature.ts` @@ -1264,7 +1293,7 @@ const createCmsSchedule = async (_, args, context) => { ```typescript import { ScheduleActionUseCase } from "@webiny/api-scheduler"; import { PublishEntryUseCase } from "~/features/contentEntry/PublishEntry/index.js"; -import { CMS_ENTRY_PUBLISH_ACTION } from "../constants.js"; +import { getCmsEntryNamespace, CMS_ACTION_TYPES } from "../constants.js"; const createCmsSchedule = async (_, args, context) => { // Get model @@ -1286,10 +1315,11 @@ const createCmsSchedule = async (_, args, context) => { const scheduleUseCase = context.container.resolve(ScheduleActionUseCase); const result = await scheduleUseCase.execute( - CMS_ENTRY_PUBLISH_ACTION, // "Cms/Entry/Publish" - args.id, // Entry ID e.g., "product#0001" - { scheduleOn: args.scheduleOn }, // When to schedule - { model } // Payload (optional, for optimization) + getCmsEntryNamespace(args.modelId), // "Cms/Entry/Article" + CMS_ACTION_TYPES.PUBLISH, // "Publish" + args.id, // Entry ID e.g., "article-1#0001" + { scheduleOn: args.scheduleOn }, // When to schedule + { model } // Payload (optional, for optimization) ); return result; @@ -1302,9 +1332,12 @@ const getCmsSchedule = async (_, args, context) => { const listCmsSchedules = async (_, args, context) => { const listUseCase = context.container.resolve(ListScheduledActionsUseCase); + + // Query all scheduled actions for this model (all action types: publish, unpublish, delete, etc.) return listUseCase.execute({ where: { - actionId: args.actionId, // Filter by action ID + namespace: getCmsEntryNamespace(args.modelId), // "Cms/Entry/Article" - gets ALL actions for Article model + actionType: args.actionType, // Optional filter by specific action type ...args.where }, sort: args.sort, @@ -1620,10 +1653,14 @@ Start with Option A to avoid breaking changes. Later, if we want to consolidate, To demonstrate extensibility, here's how you'd add email scheduling: -**Step 1: Define Action ID** +**Step 1: Define Constants** ```typescript // packages/api-mailer/src/scheduler/constants.ts -export const MAILER_EMAIL_SEND_ACTION = "Mailer/Email/Send"; +export const MAILER_ACTION_TYPES = { + SEND: "Send" +} as const; + +export const MAILER_EMAIL_NAMESPACE = "Mailer/Email"; ``` **Step 2: Create Handler** @@ -1632,8 +1669,8 @@ export const MAILER_EMAIL_SEND_ACTION = "Mailer/Email/Send"; class MailerEmailSendHandlerImpl implements ScheduledActionHandler.Interface { constructor(private emailService: EmailService) {} - canHandle(actionId: string): boolean { - return actionId === MAILER_EMAIL_SEND_ACTION; + canHandle(namespace: string, actionType: string): boolean { + return namespace === MAILER_EMAIL_NAMESPACE && actionType === MAILER_ACTION_TYPES.SEND; } async handle(action: IScheduledAction): Promise { @@ -1675,9 +1712,10 @@ const scheduleEmail = async (_, args, context) => { const scheduleUseCase = context.container.resolve(ScheduleActionUseCase); return scheduleUseCase.execute( - MAILER_EMAIL_SEND_ACTION, - `email-${generateId()}`, - { scheduleOn: args.sendAt }, + MAILER_EMAIL_NAMESPACE, // "Mailer/Email" + MAILER_ACTION_TYPES.SEND, // "Send" + `email-${generateId()}`, // Unique email ID + { scheduleOn: args.sendAt }, // When to send { to: args.to, subject: args.subject, From 49ce89682838adb8be43e570ce08955014a9bf5c Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 15:42:12 +0100 Subject: [PATCH 36/71] wip: implement api-scheduler --- .../src/EventBridgeSchedulerService.ts | 132 ++++++++++++++++++ packages/api-scheduler/src/abstractions.ts | 106 ++++++++++++++ packages/api-scheduler/src/index.ts | 9 ++ 3 files changed, 247 insertions(+) create mode 100644 packages/api-scheduler/src/EventBridgeSchedulerService.ts create mode 100644 packages/api-scheduler/src/abstractions.ts create mode 100644 packages/api-scheduler/src/index.ts diff --git a/packages/api-scheduler/src/EventBridgeSchedulerService.ts b/packages/api-scheduler/src/EventBridgeSchedulerService.ts new file mode 100644 index 00000000000..6180b4e10a4 --- /dev/null +++ b/packages/api-scheduler/src/EventBridgeSchedulerService.ts @@ -0,0 +1,132 @@ +import { WebinyError } from "@webiny/error"; +import { SchedulerService } from "./abstractions.js"; +import { + CreateScheduleCommand, + UpdateScheduleCommand, + DeleteScheduleCommand, + GetScheduleCommand, + type SchedulerClient +} from "@webiny/aws-sdk/client-scheduler"; + +export interface ISchedulerConfig { + lambdaArn: string; + roleArn: string; +} + +/** + * AWS EventBridge Scheduler implementation + * + * This is the AWS-specific implementation of the cloud-agnostic SchedulerService abstraction. + * + * Manages schedules in AWS EventBridge Scheduler for triggering Lambda functions + * at specified future times. + */ +class EventBridgeSchedulerServiceImpl implements SchedulerService.Interface { + constructor( + private getClient: (config?: any) => SchedulerClient, + private config: ISchedulerConfig + ) {} + + async create(params: { id: string; scheduleOn: Date; payload: any }): Promise { + const { id, scheduleOn, payload } = params; + + // Validate date is in future + if (scheduleOn <= new Date()) { + throw new WebinyError( + "Cannot schedule in the past", + "INVALID_SCHEDULE_DATE", + { scheduleOn, id } + ); + } + + const client = this.getClient(); + + // Check if schedule already exists (for auto-update logic) + const exists = await this.exists(id); + if (exists) { + return this.update(params); + } + + // Format: at(YYYY-MM-DDTHH:mm:ss) - EventBridge expects this format + const scheduleExpression = `at(${scheduleOn.toISOString().replace(/\.\d{3}Z$/, "")})`; + + await client.send( + new CreateScheduleCommand({ + Name: id, + ScheduleExpression: scheduleExpression, + FlexibleTimeWindow: { Mode: "OFF" }, // Exact time execution + Target: { + Arn: this.config.lambdaArn, + RoleArn: this.config.roleArn, + Input: JSON.stringify(payload) + }, + ActionAfterCompletion: "DELETE" // Auto-cleanup after execution + }) + ); + } + + async update(params: { id: string; scheduleOn: Date; payload: any }): Promise { + const { id, scheduleOn, payload } = params; + + // Validate date is in future + if (scheduleOn <= new Date()) { + throw new WebinyError( + "Cannot schedule in the past", + "INVALID_SCHEDULE_DATE", + { scheduleOn, id } + ); + } + + const client = this.getClient(); + + const scheduleExpression = `at(${scheduleOn.toISOString().replace(/\.\d{3}Z$/, "")})`; + + await client.send( + new UpdateScheduleCommand({ + Name: id, + ScheduleExpression: scheduleExpression, + FlexibleTimeWindow: { Mode: "OFF" }, + Target: { + Arn: this.config.lambdaArn, + RoleArn: this.config.roleArn, + Input: JSON.stringify(payload) + }, + ActionAfterCompletion: "DELETE" + }) + ); + } + + async delete(id: string): Promise { + const client = this.getClient(); + + try { + await client.send(new DeleteScheduleCommand({ Name: id })); + } catch (ex) { + // Ignore if schedule doesn't exist + if (ex.name === "ResourceNotFoundException") { + return; + } + throw ex; + } + } + + async exists(id: string): Promise { + const client = this.getClient(); + + try { + await client.send(new GetScheduleCommand({ Name: id })); + return true; + } catch (ex) { + if (ex.name === "ResourceNotFoundException") { + return false; + } + throw ex; + } + } +} + +export const EventBridgeSchedulerService = SchedulerService.createImplementation({ + implementation: EventBridgeSchedulerServiceImpl, + // Dependencies will be registered as instances in context + dependencies: [] +}); diff --git a/packages/api-scheduler/src/abstractions.ts b/packages/api-scheduler/src/abstractions.ts new file mode 100644 index 00000000000..ae79553f598 --- /dev/null +++ b/packages/api-scheduler/src/abstractions.ts @@ -0,0 +1,106 @@ +import { createAbstraction } from "@webiny/feature/api"; + +/** + * Identity type - represents who scheduled an action + */ +export interface Identity { + id: string; + type: string; + displayName: string; +} + +/** + * Scheduled Action Record - The data stored for a scheduled action + */ +export interface IScheduledAction { + id: string; + namespace: string; // Resource scope: "Cms/Entry/Article", "Mailer/Email" + actionType: string; // Operation: "Publish", "Unpublish", "Send", "Delete" + targetId: string; // Resource identifier (entry ID, email ID, etc.) + scheduledBy: Identity; + scheduledOn: Date; + payload?: any; // Action-specific data + error?: string; // Error if execution failed +} + +/** + * Scheduler Input - When to schedule + */ +export interface ISchedulerInput { + scheduleOn: Date; // Future date (required) +} + +/** + * List Parameters + */ +export interface ISchedulerListParams { + where?: { + namespace?: string; // Filter by resource scope + actionType?: string; // Filter by operation type + targetId?: string; // Filter by specific resource + scheduledBy?: string; // Filter by who scheduled + scheduledOn_gte?: string; + scheduledOn_lte?: string; + }; + sort?: Array; + limit?: number; + after?: string; +} + +export interface ISchedulerListResponse { + data: IScheduledAction[]; + meta: { + hasMoreItems: boolean; + totalCount: number; + cursor: string | null; + }; +} + +/** + * ScheduledActionHandler - Similar to EventHandler pattern + * + * Each application (CMS, Mailer, etc.) implements handlers for their actions. + * This is the ONLY action abstraction needed. + */ +export interface IScheduledActionHandler { + /** + * Determines if this handler can handle the given action + * + * @param namespace - Resource scope (e.g., "Cms/Entry/Article") + * @param actionType - Operation type (e.g., "Publish") + */ + canHandle(namespace: string, actionType: string): boolean; + + /** + * Executes the scheduled action + */ + handle(action: IScheduledAction): Promise; +} + +export const ScheduledActionHandler = createAbstraction( + "ScheduledActionHandler" +); + +export namespace ScheduledActionHandler { + export type Interface = IScheduledActionHandler; +} + +/** + * SchedulerService - Cloud-agnostic scheduler service + * + * Abstracts the underlying scheduling infrastructure (AWS EventBridge, Azure Logic Apps, etc.) + */ +export interface ISchedulerService { + create(params: { id: string; scheduleOn: Date; payload: any }): Promise; + update(params: { id: string; scheduleOn: Date; payload: any }): Promise; + delete(id: string): Promise; + exists(id: string): Promise; +} + +export const SchedulerService = createAbstraction( + "SchedulerService" +); + +export namespace SchedulerService { + export type Interface = ISchedulerService; +} diff --git a/packages/api-scheduler/src/index.ts b/packages/api-scheduler/src/index.ts new file mode 100644 index 00000000000..4a028de578e --- /dev/null +++ b/packages/api-scheduler/src/index.ts @@ -0,0 +1,9 @@ +// Shared abstractions +export * from "./abstractions.js"; + +// Features will be exported here as they're implemented +// export * from "./features/ScheduleAction/index.js"; +// export * from "./features/GetScheduledAction/index.js"; +// export * from "./features/ListScheduledActions/index.js"; +// export * from "./features/CancelScheduledAction/index.js"; +// export * from "./features/ExecuteScheduledAction/index.js"; From 13df0207ba5df39116d2f70defe1e54c2cdb0a1e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 20:49:33 +0100 Subject: [PATCH 37/71] wip: implement api-scheduler --- .../__tests__/handler/Handler.test.ts | 258 ++++++++++++++++++ .../actions/PublishHandlerAction.test.ts | 193 +++++++++++++ .../actions/UnpublishHandlerAction.test.ts | 206 ++++++++++++++ .../__tests__/handler/eventHandler.test.ts | 59 ++++ packages/api-scheduler/__tests__/mocks/cms.ts | 18 ++ .../__tests__/mocks/context/helpers.ts | 63 +++++ .../__tests__/mocks/context/plugins.ts | 121 ++++++++ .../mocks/context/tenancySecurity.ts | 84 ++++++ .../__tests__/mocks/context/useHandler.ts | 51 ++++ .../api-scheduler/__tests__/mocks/fetcher.ts | 10 + .../__tests__/mocks/getIdentity.ts | 12 + .../__tests__/mocks/scheduleClient.ts | 8 + .../mocks/schedulerManifestPlugin.ts | 31 +++ .../__tests__/mocks/schedulerModel.ts | 13 + .../api-scheduler/__tests__/mocks/security.ts | 16 ++ .../api-scheduler/__tests__/mocks/service.ts | 12 + .../__tests__/mocks/targetModel.ts | 51 ++++ .../scheduler/ScheduleExecutor.test.ts | 70 +++++ .../scheduler/ScheduleFetcher.test.ts | 100 +++++++ .../__tests__/scheduler/Scheduler.test.ts | 68 +++++ .../actions/PublishScheduleAction.test.ts | 231 ++++++++++++++++ .../actions/UnpublishScheduleAction.test.ts | 220 +++++++++++++++ .../scheduler/createScheduleRecordId.test.ts | 40 +++ .../scheduler/createScheduler.test.ts | 31 +++ .../service/SchedulerService.test.ts | 220 +++++++++++++++ packages/api-scheduler/src/constants.ts | 7 + packages/api-scheduler/src/context.ts | 48 ++++ packages/api-scheduler/src/domain/errors.ts | 57 ++++ packages/api-scheduler/src/domain/model.ts | 93 +++++++ .../CancelScheduledActionUseCase.ts | 78 ++++++ .../CancelScheduledAction/abstractions.ts | 36 +++ .../features/CancelScheduledAction/feature.ts | 15 + .../features/CancelScheduledAction/index.ts | 1 + .../ExecuteScheduledActionUseCase.ts | 112 ++++++++ .../ScheduledActionHandlerComposite.ts | 34 +++ .../ExecuteScheduledAction/abstractions.ts | 68 +++++ .../ExecuteScheduledAction/feature.ts | 15 + .../features/ExecuteScheduledAction/index.ts | 1 + .../GetScheduledActionUseCase.ts | 52 ++++ .../GetScheduledAction/abstractions.ts | 33 +++ .../features/GetScheduledAction/feature.ts | 15 + .../src/features/GetScheduledAction/index.ts | 1 + .../ListScheduledActionsUseCase.ts | 69 +++++ .../ListScheduledActions/abstractions.ts | 61 +++++ .../features/ListScheduledActions/feature.ts | 15 + .../features/ListScheduledActions/index.ts | 1 + .../ScheduleAction/ScheduleActionUseCase.ts | 216 +++++++++++++++ .../features/ScheduleAction/abstractions.ts | 43 +++ .../src/features/ScheduleAction/feature.ts | 15 + .../src/features/ScheduleAction/index.ts | 1 + .../src/features/SchedulerFeature.ts | 29 ++ .../EventBridgeSchedulerService.ts | 12 +- .../SchedulerService/VoidSchedulerService.ts | 19 ++ packages/api-scheduler/src/index.ts | 18 +- packages/api-scheduler/src/manifest.ts | 61 +++++ .../src/{ => shared}/abstractions.ts | 57 ++-- 56 files changed, 3415 insertions(+), 54 deletions(-) create mode 100644 packages/api-scheduler/__tests__/handler/Handler.test.ts create mode 100644 packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts create mode 100644 packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts create mode 100644 packages/api-scheduler/__tests__/handler/eventHandler.test.ts create mode 100644 packages/api-scheduler/__tests__/mocks/cms.ts create mode 100644 packages/api-scheduler/__tests__/mocks/context/helpers.ts create mode 100644 packages/api-scheduler/__tests__/mocks/context/plugins.ts create mode 100644 packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts create mode 100644 packages/api-scheduler/__tests__/mocks/context/useHandler.ts create mode 100644 packages/api-scheduler/__tests__/mocks/fetcher.ts create mode 100644 packages/api-scheduler/__tests__/mocks/getIdentity.ts create mode 100644 packages/api-scheduler/__tests__/mocks/scheduleClient.ts create mode 100644 packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts create mode 100644 packages/api-scheduler/__tests__/mocks/schedulerModel.ts create mode 100644 packages/api-scheduler/__tests__/mocks/security.ts create mode 100644 packages/api-scheduler/__tests__/mocks/service.ts create mode 100644 packages/api-scheduler/__tests__/mocks/targetModel.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts create mode 100644 packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts create mode 100644 packages/api-scheduler/__tests__/service/SchedulerService.test.ts create mode 100644 packages/api-scheduler/src/constants.ts create mode 100644 packages/api-scheduler/src/context.ts create mode 100644 packages/api-scheduler/src/domain/errors.ts create mode 100644 packages/api-scheduler/src/domain/model.ts create mode 100644 packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts create mode 100644 packages/api-scheduler/src/features/CancelScheduledAction/abstractions.ts create mode 100644 packages/api-scheduler/src/features/CancelScheduledAction/feature.ts create mode 100644 packages/api-scheduler/src/features/CancelScheduledAction/index.ts create mode 100644 packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts create mode 100644 packages/api-scheduler/src/features/ExecuteScheduledAction/ScheduledActionHandlerComposite.ts create mode 100644 packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts create mode 100644 packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts create mode 100644 packages/api-scheduler/src/features/ExecuteScheduledAction/index.ts create mode 100644 packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts create mode 100644 packages/api-scheduler/src/features/GetScheduledAction/abstractions.ts create mode 100644 packages/api-scheduler/src/features/GetScheduledAction/feature.ts create mode 100644 packages/api-scheduler/src/features/GetScheduledAction/index.ts create mode 100644 packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts create mode 100644 packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts create mode 100644 packages/api-scheduler/src/features/ListScheduledActions/feature.ts create mode 100644 packages/api-scheduler/src/features/ListScheduledActions/index.ts create mode 100644 packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts create mode 100644 packages/api-scheduler/src/features/ScheduleAction/abstractions.ts create mode 100644 packages/api-scheduler/src/features/ScheduleAction/feature.ts create mode 100644 packages/api-scheduler/src/features/ScheduleAction/index.ts create mode 100644 packages/api-scheduler/src/features/SchedulerFeature.ts rename packages/api-scheduler/src/{ => features/SchedulerService}/EventBridgeSchedulerService.ts (89%) create mode 100644 packages/api-scheduler/src/features/SchedulerService/VoidSchedulerService.ts create mode 100644 packages/api-scheduler/src/manifest.ts rename packages/api-scheduler/src/{ => shared}/abstractions.ts (60%) diff --git a/packages/api-scheduler/__tests__/handler/Handler.test.ts b/packages/api-scheduler/__tests__/handler/Handler.test.ts new file mode 100644 index 00000000000..d5f7767cda6 --- /dev/null +++ b/packages/api-scheduler/__tests__/handler/Handler.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useHandler } from "~tests/mocks/context/useHandler.js"; +import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; +import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; +import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; +import type { CmsContext, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { + createScheduleRecordId, + createScheduleRecordIdWithVersion +} from "~/scheduler/createScheduleRecordId.js"; +import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; +import { dateToISOString } from "~/scheduler/dates.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import { ProcessRecordsUseCase } from "~/features/ProcessRecords/ProcessRecordsUseCase.js"; +import { + ProcessRecordsUseCase as ProcessRecordsAbstraction, + RecordAction +} from "~/features/ProcessRecords/index.js"; +import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; +import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; +import { SchedulerFactory } from "~/features/Scheduler/abstractions"; + +const createEventScheduleRecordId = (targetId: string): string => { + return `${createScheduleRecordIdWithVersion(targetId)}`; +}; + +describe("Handler", () => { + const targetId = "target-id#0001"; + + let context: CmsContext; + + beforeEach(async () => { + const contextHandler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + context = await contextHandler.handler(); + }); + + const createScheduleEntry = async ( + values: Omit + ): Promise> => { + const getModel = context.container.resolve(GetModelUseCase); + const createEntry = context.container.resolve(CreateEntryUseCase); + + const modelResult = await getModel.execute(SCHEDULE_MODEL_ID); + const model = modelResult.value; + + const res = await createEntry.execute(model, { + id: createScheduleRecordId(values.targetId), + ...values, + targetModelId: MOCK_TARGET_MODEL_ID + }); + + return res.value as CmsEntry; + }; + + it("should fail to handle due to missing schedule entry", async () => { + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); + + try { + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() + } + }); + + if (result.isFail()) { + throw result.error; + } + + expect(result).toEqual("SHOULD NOT REACH HERE."); + } catch (ex) { + expect(ex.message).toEqual( + `Entry "${createEventScheduleRecordId(targetId)}" was not found!` + ); + expect(ex.code).toEqual("Cms/Entry/NotFound"); + } + }); + + it("should fail to find action", async () => { + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.register(UnpublishRecordAction); + + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); + + const scheduleEntry = await createScheduleEntry({ + targetId, + type: ScheduleType.publish, + title: "Test Entry", + scheduledOn: dateToISOString(new Date()), + scheduledBy: context.security.getIdentity() + }); + + expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); + + try { + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() + } + }); + + if (result.isFail()) { + throw result.error; + } + + expect(result).toEqual("SHOULD NOT REACH HERE."); + } catch (ex) { + expect(ex.message).toEqual( + `No action found for schedule record ID: wby-schedule-target-id-0001#0001` + ); + } + }); + + it("should handle action", async () => { + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.register(PublishRecordAction); + + // Resolve and execute + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); + + const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const targetEntry = await context.cms.createEntry(targetModel, { + id: "target-id", + title: "Test Entry" + }); + + expect(targetEntry.id).toEqual(targetId); + + const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); + const scheduleEntry = await createScheduleEntry({ + targetId, + type: ScheduleType.publish, + title: "Test Entry", + scheduledOn: dateToISOString(new Date()), + scheduledBy: context.security.getIdentity() + }); + + expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); + + const schedulerFactory = testContainer.resolve(SchedulerFactory); + const scheduler = schedulerFactory.useModel(targetModel); + + const getScheduleEntry = await scheduler.getScheduled(createScheduleRecordId(targetId)); + + expect(getScheduleEntry).toMatchObject({ + id: expect.any(String), + targetId, + model: targetModel, + title: "Test Entry", + publishOn: expect.any(Date), + unpublishOn: undefined, + type: ScheduleType.publish + }); + + await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() + } + }); + + const [afterDeleteScheduledEntry] = await context.cms.getEntriesByIds(scheduleModel, [ + scheduleEntry.id + ]); + + expect(afterDeleteScheduledEntry).toBeUndefined(); + + const [afterActionTargetEntry] = await context.cms.getPublishedEntriesByIds(targetModel, [ + targetId + ]); + expect(afterActionTargetEntry).toMatchObject({ + id: targetId, + values: { + title: "Test Entry" + }, + status: "published" + }); + }); + + it("should throw an error while handling action", async () => { + const testContainer = context.container.createChildContainer(); + // Register a use case without actions + testContainer.register(ProcessRecordsUseCase); + testContainer.registerInstance(RecordAction, { + canHandle: () => true, + async handle(): Promise { + throw new Error("Unknown error."); + } + }); + + const processRecords = testContainer.resolve(ProcessRecordsAbstraction); + + const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const targetEntry = await context.cms.createEntry(targetModel, { + id: "target-id", + title: "Test Entry" + }); + expect(targetEntry.id).toEqual(targetId); + + const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); + const scheduleEntry = await createScheduleEntry({ + targetId, + type: ScheduleType.publish, + title: "Test Entry", + scheduledOn: dateToISOString(new Date()), + scheduledBy: context.security.getIdentity() + }); + + expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); + + try { + const result = await processRecords.execute({ + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createEventScheduleRecordId(targetId), + scheduleOn: new Date().toISOString() + } + }); + + if (result.isFail()) { + throw result.error; + } + + expect(result).toEqual("SHOULD NOT REACH HERE."); + } catch (ex) { + expect(ex.message).toEqual("Unknown error."); + } + + const afterActionErrorScheduleEntry = await context.cms.getEntryById( + scheduleModel, + scheduleEntry.id + ); + expect(afterActionErrorScheduleEntry).toMatchObject({ + id: scheduleEntry.id, + values: { + title: "Test Entry", + error: "Unknown error." + } + }); + }); +}); diff --git a/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts b/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts new file mode 100644 index 00000000000..274243385e5 --- /dev/null +++ b/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import { useHandler } from "~tests/mocks/context/useHandler.js"; +import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; +import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; +import { ScheduleType } from "~/scheduler/types.js"; +import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; +import { RecordAction } from "~/features/ProcessRecords/index.js"; + +describe("PublishHandlerAction", () => { + it("should only handle publish action", async () => { + // @ts-expect-error No deps provided; we only test a `canHandle`. + const action = new PublishRecordAction(); + + expect(action.canHandle({ type: ScheduleType.publish })).toBe(true); + expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(false); + }); + + it("should throw an error if target entry does not exist", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); + + try { + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + expect(result).toEqual("Should not reach here."); + } catch (ex) { + expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); + } + }); + + it("should publish an entry which is not published yet", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(publishedEntry.id).toBe("target-id#0001"); + }); + + it("should republish an entry which is already published", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(publishedEntry.id).toBe("target-id#0001"); + + // @ts-expect-error We only want to test the base execution. + await action.handle({ + targetId: "target-id#0001", + model + }); + const [rePublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(rePublishedEntry.id).toBe("target-id#0001"); + const publishedDate = new Date(publishedEntry.lastPublishedOn!); + const rePublishedDate = new Date(rePublishedEntry.lastPublishedOn!); + expect(rePublishedDate > publishedDate).toBeTrue(); + }); + + it("should publish a new entry revision if the existing published revision is different", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(PublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + + expect(action).toBeInstanceOf(PublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + await context.cms.getPublishedEntriesByIds(model, ["target-id#0001"]); + + const newEntryRevision = await context.cms.createEntryRevisionFrom( + model, + "target-id#0001", + { + title: "Test Entry - Updated" + } + ); + expect(newEntryRevision).toMatchObject({ + id: "target-id#0002", + values: { + title: "Test Entry - Updated" + } + }); + + // @ts-expect-error We only want to test the base execution. + await action.handle({ + targetId: "target-id#0002", + model + }); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, ["target-id"]); + expect(publishedEntry.id).toBe("target-id#0002"); + }); +}); diff --git a/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts b/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts new file mode 100644 index 00000000000..c25df5c6dc3 --- /dev/null +++ b/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts @@ -0,0 +1,206 @@ +import { useHandler } from "~tests/mocks/context/useHandler.js"; +import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; +import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; +import { ScheduleType } from "~/scheduler/types.js"; +import { describe, expect, it, vi } from "vitest"; +import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; +import { RecordAction } from "~/features/ProcessRecords/index.js"; + +describe("UnpublishHandlerAction", () => { + it("should only handle unpublish action", async () => { + // @ts-expect-error No deps provided; we only test a `canHandle`. + const action = new UnpublishRecordAction(); + + expect(action.canHandle({ type: ScheduleType.publish })).toBe(false); + expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(true); + }); + + it("should throw an error if target entry does not exist", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); + + try { + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + expect(result).toEqual("Should not reach here."); + } catch (ex) { + expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); + } + }); + + it("should do nothing if entry is not published", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + console.warn = vi.fn(); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + expect(console.warn).toHaveBeenCalledWith( + `Entry "target-id#0001" is not published, nothing to unpublish.` + ); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(publishedEntry).toBeUndefined(); + }); + + it("should unpublish an entry if it matches", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + await context.cms.publishEntry(model, "target-id#0001"); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(publishedEntry.id).toBe("target-id#0001"); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + // @ts-expect-error We only want to test the base execution. + await action.handle({ + targetId: "target-id#0001", + model + }); + const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(unpublishedEntry).toBeUndefined(); + }); + + it("should unpublish entry even if it does not match the target ID (revision).", async () => { + const handler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + const context = await handler.handler(); + + const testContainer = context.container.createChildContainer(); + testContainer.register(UnpublishRecordAction); + + const action = testContainer.resolveAll(RecordAction)[0]; + expect(action).toBeInstanceOf(UnpublishRecordAction); + + const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); + + const entry = await context.cms.createEntry(model, { + id: "target-id", + title: "Test Entry" + }); + expect(entry.id).toEqual("target-id#0001"); + + await context.cms.publishEntry(model, "target-id#0001"); + + const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(publishedEntry.id).toBe("target-id#0001"); + + const newEntryRevision = await context.cms.createEntryRevisionFrom( + model, + publishedEntry.id, + { + title: "Test Entry - Updated" + } + ); + + expect(newEntryRevision.id).toEqual("target-id#0002"); + + await context.cms.publishEntry(model, "target-id#0002"); + + const [publishedOverwriteEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0002" + ]); + + expect(publishedOverwriteEntry.id).toBe("target-id#0002"); + + // @ts-expect-error We only want to test the base execution. + const result = await action.handle({ + targetId: "target-id#0001", + model + }); + + expect(result).toBeUndefined(); + + // @ts-expect-error We only want to test the base execution. + await action.handle({ + targetId: "target-id#0001", + model + }); + const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ + "target-id#0001" + ]); + + expect(unpublishedEntry).toBeUndefined(); + }); +}); diff --git a/packages/api-scheduler/__tests__/handler/eventHandler.test.ts b/packages/api-scheduler/__tests__/handler/eventHandler.test.ts new file mode 100644 index 00000000000..e9a65365540 --- /dev/null +++ b/packages/api-scheduler/__tests__/handler/eventHandler.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { RawEventHandler } from "@webiny/handler-aws/raw/index.js"; +import { createScheduledCmsActionEventHandler } from "~/handler/index.js"; +import { registry } from "@webiny/handler-aws/registry.js"; +import type { LambdaContext } from "@webiny/handler-aws/types.js"; +import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; +import { createScheduleRecordId } from "~/scheduler/createScheduleRecordId.js"; +import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; + +describe("Scheduler Event Handler", () => { + const lambdaContext = {} as LambdaContext; + it("should trigger handle an event which matches scheduled event", async () => { + const eventHandler = createScheduledCmsActionEventHandler(); + + expect(eventHandler).toBeInstanceOf(RawEventHandler); + + const event: IWebinyScheduledCmsActionEvent = { + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: createScheduleRecordId("target-id#0001"), + scheduleOn: new Date().toISOString() + } + }; + const sourceHandler = registry.getHandler(event, lambdaContext); + + expect(sourceHandler).toMatchObject({ + name: "handler-aws-event-bridge-scheduled-cms-action-event" + }); + expect(sourceHandler.canUse(event, lambdaContext)).toBe(true); + + /** + * Should break because there are no contexts loaded. + */ + const result = await sourceHandler.handle({ + params: { + plugins: [eventHandler] + }, + event, + context: lambdaContext + }); + /** + * We are expecting an error because the context is not set up properly - we dont need it to be set up. + */ + expect(result).toEqual({ + body: '{"message":"No registration found for SchedulerFactory"}', + headers: { + "access-control-allow-headers": "*", + "access-control-allow-methods": "POST", + "access-control-allow-origin": "*", + "cache-control": "no-store", + connection: "keep-alive", + "content-length": "56", + "content-type": "text/plain; charset=utf-8", + date: expect.toBeDateString() + }, + isBase64Encoded: false, + statusCode: 500 + }); + }); +}); diff --git a/packages/api-scheduler/__tests__/mocks/cms.ts b/packages/api-scheduler/__tests__/mocks/cms.ts new file mode 100644 index 00000000000..0fd27d8e0d7 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/cms.ts @@ -0,0 +1,18 @@ +import { vi } from "vitest"; +import type { ScheduleExecutorCms } from "~/scheduler/ScheduleExecutor.js"; +import type { ScheduleFetcherCms } from "~/scheduler/ScheduleFetcher.js"; + +export const createMockCms = ( + cms?: Partial +): ScheduleExecutorCms & ScheduleFetcherCms => { + return { + listLatestEntries: vi.fn(), + publishEntry: vi.fn(), + unpublishEntry: vi.fn(), + updateEntry: vi.fn(), + createEntry: vi.fn(), + deleteEntry: vi.fn(), + getEntryById: vi.fn(), + ...cms + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/context/helpers.ts b/packages/api-scheduler/__tests__/mocks/context/helpers.ts new file mode 100644 index 00000000000..0fdda22b2a0 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/context/helpers.ts @@ -0,0 +1,63 @@ +import type { IdentityData } from "@webiny/api-core/features/IdentityContext"; + +export interface PermissionsArg { + name: string; + locales?: string[]; + rwd?: string; + pw?: string; + own?: boolean; +} + +export const identity = { + id: "id-12345678", + displayName: "John Doe", + type: "admin" +}; + +const getSecurityIdentity = () => { + return identity; +}; + +export const createPermissions = (permissions?: PermissionsArg[]): PermissionsArg[] => { + if (permissions) { + return permissions; + } + return [ + { + name: "cms.settings" + }, + { + name: "cms.contentModel", + rwd: "rwd" + }, + { + name: "cms.contentModelGroup", + rwd: "rwd" + }, + { + name: "cms.contentEntry", + rwd: "rwd", + pw: "rcpu" + }, + { + name: "cms.endpoint.read" + }, + { + name: "cms.endpoint.manage" + }, + { + name: "cms.endpoint.preview" + }, + { + name: "content.i18n", + locales: ["en-US", "de-DE"] + } + ]; +}; + +export const createIdentity = (identity?: IdentityData) => { + if (!identity) { + return getSecurityIdentity(); + } + return identity; +}; diff --git a/packages/api-scheduler/__tests__/mocks/context/plugins.ts b/packages/api-scheduler/__tests__/mocks/context/plugins.ts new file mode 100644 index 00000000000..634939a78d4 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/context/plugins.ts @@ -0,0 +1,121 @@ +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense.js"; +import graphQLHandlerPlugins from "@webiny/handler-graphql"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; +import { createTenancyAndSecurity } from "./tenancySecurity"; +import type { PermissionsArg } from "./helpers"; +import { createPermissions } from "./helpers"; +import type { ContextPlugin } from "@webiny/api"; +import type { ScheduleContext } from "~/types.js"; +import type { Plugin, PluginCollection } from "@webiny/plugins/types"; +import { getStorageOps } from "@webiny/project-utils/testing/environment"; +import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; +import { createHeadlessCmsScheduler } from "~/index.js"; +import type { + SchedulerClient, + SchedulerClientConfig +} from "@webiny/aws-sdk/client-scheduler/index.js"; +import { createSchedulerManifestPlugin } from "~tests/mocks/schedulerManifestPlugin.js"; +import { createMockTargetModelPlugins } from "~tests/mocks/targetModel.js"; +import apiKeyAuthentication from "@webiny/api-core/legacy/security/plugins/apiKeyAuthentication.js"; +import apiKeyAuthorization from "@webiny/api-core/legacy/security/plugins/apiKeyAuthorization.js"; +import type { ApiKey } from "@webiny/api-core/types/security.js"; +import { createApiCore } from "@webiny/api-core"; +import type { IdentityData } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js"; + +export interface CreateHandlerCoreParams { + getScheduleClient: (config?: SchedulerClientConfig) => Pick; + setupTenancyAndSecurityGraphQL?: boolean; + permissions?: PermissionsArg[]; + identity?: IdentityData; + topPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + plugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + bottomPlugins?: Plugin | Plugin[] | Plugin[][] | PluginCollection; + path?: `manage/${string}-${string}}` | `read/${string}-${string}}` | string; +} + +process.env.S3_BUCKET = "my-mock-s3-bucket"; + +export const createHandlerCore = (params: CreateHandlerCoreParams) => { + const tenant = { + id: "root", + name: "Root", + parent: null + }; + const locale = "en-US"; + const { + permissions, + identity, + plugins = [], + topPlugins = [], + bottomPlugins = [], + setupTenancyAndSecurityGraphQL + } = params; + + const apiCoreStorage = getStorageOps("apiCore"); + const cmsStorage = getStorageOps("cms"); + + return { + storageOperations: cmsStorage.storageOperations, + tenant, + locale, + plugins: [ + createMockTargetModelPlugins(), + topPlugins, + ...cmsStorage.plugins, + createApiCore({ + storageOperations: apiCoreStorage.storageOperations, + testProjectLicense: createTestWcpLicense() + }), + ...createTenancyAndSecurity({ + setupGraphQL: setupTenancyAndSecurityGraphQL, + permissions: createPermissions(permissions), + identity + }), + createSchedulerManifestPlugin(), + { + type: "context", + name: "context-security-tenant", + async apply(context) { + context.security.getApiKeyByToken = async ( + token: string + ): Promise => { + if (!token || token !== "aToken") { + return null; + } + const apiKey = "a1234567890"; + return { + id: apiKey, + name: apiKey, + tenant: tenant.id, + permissions: identity?.permissions || [], + token, + createdBy: { + id: "test", + displayName: "test", + type: "admin" + }, + description: "test", + createdOn: new Date().toISOString(), + webinyVersion: context.WEBINY_VERSION + }; + }; + } + } as ContextPlugin, + apiKeyAuthentication({ identityType: "api-key" }), + apiKeyAuthorization({ identityType: "api-key" }), + createHeadlessCmsContext({ + storageOperations: cmsStorage.storageOperations + }), + createHeadlessCmsGraphQL(), + plugins, + graphQLHandlerPlugins(), + createHeadlessCmsScheduler({ + getClient: config => { + return params.getScheduleClient(config); + } + }), + bottomPlugins + ] + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts new file mode 100644 index 00000000000..46d43e0e387 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts @@ -0,0 +1,84 @@ +import type { Plugin } from "@webiny/plugins/Plugin"; +import { ContextPlugin } from "@webiny/api"; +import { BeforeHandlerPlugin } from "@webiny/handler"; +import type { ScheduleContext } from "~/types.js"; +import { SecurityPermission } from "@webiny/api-core/types/security"; +import { IdentityData } from "@webiny/api-core/features/IdentityContext"; +import type { Tenant } from "@webiny/api-core/types/tenancy.js"; + +interface Config { + permissions: SecurityPermission[]; + identity?: IdentityData | null; +} + +export const defaultIdentity: IdentityData = { + id: "id-12345678", + type: "admin", + displayName: "John Doe" +}; + +export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plugin[] => { + return [ + new ContextPlugin(async context => { + await context.tenancy.createTenant({ + id: "root", + name: "Root", + parent: "", + description: "Root tenant", + tags: [] + }); + + await context.tenancy.createTenant({ + id: "webiny", + name: "Webiny", + parent: "", + description: "Webiny tenant", + tags: [] + }); + + await context.tenancy.createTenant({ + id: "dev", + name: "Dev", + parent: "", + description: "Dev tenant", + tags: [] + }); + + await context.tenancy.createTenant({ + id: "sales", + name: "Sales", + parent: "", + description: "Sales tenant", + tags: [] + }); + }), + new ContextPlugin(async context => { + context.tenancy.setCurrentTenant({ + id: "root", + name: "Root", + webinyVersion: context.WEBINY_VERSION + } as unknown as Tenant); + + context.security.addAuthenticator(async () => { + return identity || defaultIdentity; + }); + + context.security.addAuthorizer(async () => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return null; + } + + return permissions || [{ name: "*" }]; + }); + }), + new BeforeHandlerPlugin(context => { + const { headers = {} } = context.request || {}; + if (headers["authorization"]) { + return context.security.authenticate(headers["authorization"]); + } + + return context.security.authenticate(""); + }) + ].filter(Boolean) as Plugin[]; +}; diff --git a/packages/api-scheduler/__tests__/mocks/context/useHandler.ts b/packages/api-scheduler/__tests__/mocks/context/useHandler.ts new file mode 100644 index 00000000000..79d19c82789 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/context/useHandler.ts @@ -0,0 +1,51 @@ +import type { CreateHandlerCoreParams } from "./plugins"; +import { createHandlerCore } from "./plugins"; +import { createRawEventHandler, createRawHandler } from "@webiny/handler-aws"; +import { defaultIdentity } from "./tenancySecurity"; +import type { LambdaContext } from "@webiny/handler-aws/types"; +import { getElasticsearchClient } from "@webiny/project-utils/testing/elasticsearch"; +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; + +interface CmsHandlerEvent { + path: string; + headers: { + ["x-tenant"]: string; + [key: string]: string; + }; +} + +export const useHandler = (params: CreateHandlerCoreParams) => { + const core = createHandlerCore(params); + + const plugins = [...core.plugins].concat([ + createRawEventHandler(async ({ context }) => { + return context; + }) + ]); + + const handler = createRawHandler({ + plugins, + debug: process.env.DEBUG === "true" + }); + + const { elasticsearchClient } = getElasticsearchClient({ name: "api-headless-cms-ddb-es" }); + + return { + plugins, + identity: params.identity || defaultIdentity, + tenant: core.tenant, + locale: core.locale, + elasticsearch: elasticsearchClient, + handler: (input?: CmsHandlerEvent) => { + const payload: CmsHandlerEvent = { + path: "/cms/manage", + headers: { + "x-webiny-cms-endpoint": "manage", + "x-tenant": "root" + }, + ...input + }; + return handler(payload, {} as LambdaContext); + } + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/fetcher.ts b/packages/api-scheduler/__tests__/mocks/fetcher.ts new file mode 100644 index 00000000000..3a6a69af5e5 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/fetcher.ts @@ -0,0 +1,10 @@ +import { vi } from "vitest"; +import type { IScheduleFetcher } from "~/scheduler/types.js"; + +export const createMockFetcher = (input?: Partial): IScheduleFetcher => { + return { + getScheduled: vi.fn(), + listScheduled: vi.fn(), + ...input + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/getIdentity.ts b/packages/api-scheduler/__tests__/mocks/getIdentity.ts new file mode 100644 index 00000000000..1bb99cdf8a3 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/getIdentity.ts @@ -0,0 +1,12 @@ +import type { CmsIdentity } from "@webiny/api-headless-cms/types"; + +export const createMockGetIdentity = (identity?: CmsIdentity) => { + return (): CmsIdentity => { + return { + id: "mock-identity-id", + type: "admin", + displayName: "Mock Identity", + ...identity + }; + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/scheduleClient.ts b/packages/api-scheduler/__tests__/mocks/scheduleClient.ts new file mode 100644 index 00000000000..bc90ab51836 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/scheduleClient.ts @@ -0,0 +1,8 @@ +import { vi } from "vitest"; +import type { SchedulerClient } from "@webiny/aws-sdk/client-scheduler"; + +export const createMockScheduleClient = (send = vi.fn()): Pick => { + return { + send: send || vi.fn() + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts b/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts new file mode 100644 index 00000000000..c4504fa3c25 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts @@ -0,0 +1,31 @@ +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { PutCommand } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import type { ScheduleContext } from "~/types.js"; +import { ContextPlugin } from "@webiny/api"; + +export const createSchedulerManifestPlugin = () => { + return new ContextPlugin(async context => { + const manifest = { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-scheduler-function", + roleArn: "arn:aws:iam::123456789012:role/my-scheduler-role" + }; + + const client = context.db.driver.getClient() as DynamoDBDocument; + + await client.send( + new PutCommand({ + TableName: process.env.DB_TABLE, + Item: { + PK: "SERVICE_MANIFEST#api#scheduler", + SK: "default", + GSI1_PK: "SERVICE_MANIFESTS", + GSI1_SK: "api#scheduler", + data: { + name: "scheduler", + manifest + } + } + }) + ); + }); +}; diff --git a/packages/api-scheduler/__tests__/mocks/schedulerModel.ts b/packages/api-scheduler/__tests__/mocks/schedulerModel.ts new file mode 100644 index 00000000000..01e12ad0091 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/schedulerModel.ts @@ -0,0 +1,13 @@ +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import { createSchedulerModel } from "~/scheduler/model.js"; + +export const createMockSchedulerModel = (input?: Partial): CmsModel => { + const model = createSchedulerModel(); + return { + ...model.contentModel, + webinyVersion: "0.0.0", + tenant: "root", + locale: "en-US", + ...input + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/security.ts b/packages/api-scheduler/__tests__/mocks/security.ts new file mode 100644 index 00000000000..952b933ca8b --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/security.ts @@ -0,0 +1,16 @@ +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; +import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; + +export const createMockSecurity = ( + input?: Partial> +): Pick => { + const getIdentity = createMockGetIdentity(); + return { + // @ts-expect-error + getIdentity, + withoutAuthorization: (cb: () => Promise) => { + return cb(); + }, + ...input + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/service.ts b/packages/api-scheduler/__tests__/mocks/service.ts new file mode 100644 index 00000000000..a7ff87a4207 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/service.ts @@ -0,0 +1,12 @@ +import { vi } from "vitest"; +import type { ISchedulerService } from "~/service/types.js"; + +export const createMockService = (input?: Partial): ISchedulerService => { + return { + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + ...input + }; +}; diff --git a/packages/api-scheduler/__tests__/mocks/targetModel.ts b/packages/api-scheduler/__tests__/mocks/targetModel.ts new file mode 100644 index 00000000000..ea6d7c8b119 --- /dev/null +++ b/packages/api-scheduler/__tests__/mocks/targetModel.ts @@ -0,0 +1,51 @@ +import { createModelGroupPlugin, createModelPlugin } from "@webiny/api-headless-cms"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; + +const group = { + id: "default", + name: "Default Group" +}; + +export const MOCK_TARGET_MODEL_ID = "targetModel"; + +export const createMockTargetModel = (): CmsModel => { + return { + modelId: MOCK_TARGET_MODEL_ID, + name: "Target Model", + description: "This is a mock target model for testing purposes.", + fields: [ + { + id: "title", + fieldId: "title", + storageId: "text@title", + type: "text", + label: "Title" + } + ], + group, + singularApiName: "targetModel", + pluralApiName: "targetModels", + layout: [["title"]], + createdOn: new Date().toISOString(), + savedOn: new Date().toISOString(), + webinyVersion: "0.0.0", + tenant: "root", + locale: "en-US", + titleFieldId: "title" + }; +}; + +export const createMockTargetModelPlugins = () => { + return [ + createModelGroupPlugin({ + ...group, + slug: "default", + description: null, + icon: "fa/fas" + }), + createModelPlugin({ + ...createMockTargetModel(), + isPrivate: undefined + }) + ]; +}; diff --git a/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts b/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts new file mode 100644 index 00000000000..d66942e1734 --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { ScheduleExecutor } from "~/scheduler/ScheduleExecutor.js"; +import { createMockFetcher } from "~tests/mocks/fetcher.js"; +import { type IScheduleRecord, ScheduleType } from "~/scheduler/types.js"; +import { createScheduleRecord } from "~/scheduler/ScheduleRecord.js"; +import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; + +describe("ScheduleExecutor", () => { + it("should execute not find action for publishing", async () => { + const fetcher = createMockFetcher({ + async getScheduled(targetId: string): Promise { + return createScheduleRecord({ + id: `schedule-record-id-${targetId}`, + targetId, + scheduledOn: new Date(), + type: ScheduleType.publish, + title: `Scheduled for ${targetId}`, + scheduledBy: createMockGetIdentity()(), + model: {} as any + }); + } + }); + const executor = new ScheduleExecutor({ + actions: [], + fetcher + }); + + await expect( + executor.schedule("target-id#0001", { + type: ScheduleType.publish, + scheduleOn: new Date() + }) + ).rejects.toThrow(`No action found for input type "${ScheduleType.publish}"`); + + await expect(executor.cancel("target-id#0001")).rejects.toThrow( + `No action found for input type "${ScheduleType.publish}"` + ); + }); + + it("should execute not find action for unpublishing", async () => { + const fetcher = createMockFetcher({ + async getScheduled(targetId: string): Promise { + return createScheduleRecord({ + id: `schedule-record-id-${targetId}`, + targetId, + scheduledOn: new Date(), + type: ScheduleType.unpublish, + title: `Scheduled for ${targetId}`, + scheduledBy: createMockGetIdentity()(), + model: {} as any + }); + } + }); + const executor = new ScheduleExecutor({ + actions: [], + fetcher + }); + + await expect( + executor.schedule("target-id#0001", { + type: ScheduleType.unpublish, + scheduleOn: new Date() + }) + ).rejects.toThrow(`No action found for input type "${ScheduleType.unpublish}"`); + + await expect(executor.cancel("target-id#0001")).rejects.toThrow( + `No action found for input type "${ScheduleType.unpublish}"` + ); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts b/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts new file mode 100644 index 00000000000..e9dc12a02ca --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts @@ -0,0 +1,100 @@ +import { ScheduleFetcher } from "~/scheduler/ScheduleFetcher.js"; +import type { + CmsEntryMeta, + CmsIdentity, + CmsModel, + HeadlessCms +} from "@webiny/api-headless-cms/types/index.js"; +import type { ISchedulerListParams } from "~/scheduler/types.js"; +import { NotFoundError } from "@webiny/handler-graphql"; +import { dateToISOString } from "~/scheduler/dates.js"; +import { describe, expect, it, vi } from "vitest"; + +describe("ScheduleFetcher", () => { + const targetModel: CmsModel = { modelId: "targetModel", titleFieldId: "title" } as any; + const schedulerModel: CmsModel = { modelId: "schedulerModel", titleFieldId: "title" } as any; + const entryMeta: CmsEntryMeta = { hasMoreItems: false, totalCount: 1, cursor: null }; + + function createMockCms(overrides: Partial = {}) { + return { + getEntryById: vi.fn(), + listLatestEntries: vi.fn(), + ...overrides + } as unknown as HeadlessCms; + } + + it("getScheduled returns a record if found", async () => { + const identity: CmsIdentity = { + id: "user-1", + displayName: "User 1", + type: "admin" + }; + const mockedEntry = { + id: "schedule-1", + values: { + type: "publish", + title: "Test Entry", + targetModelId: targetModel.modelId, + targetId: "target-1", + scheduledOn: dateToISOString(new Date()), + scheduledBy: identity + }, + savedOn: new Date().toISOString(), + savedBy: identity + }; + const cms = createMockCms({ + getEntryById: vi.fn().mockResolvedValue(mockedEntry) + }); + const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); + const result = await fetcher.getScheduled("target-1"); + expect(result).not.toBeNull(); + expect(result?.targetId).toBe("target-1"); + expect(result?.title).toBe("Test Entry"); + }); + + it("getScheduled returns null if not found", async () => { + const cms = createMockCms({ + getEntryById: vi.fn().mockRejectedValue(new NotFoundError("not found")) + }); + const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); + const result = await fetcher.getScheduled("target-1"); + expect(result).toBeNull(); + }); + + it("getScheduled throws on unknown error", async () => { + const cms = createMockCms({ + getEntryById: vi.fn().mockRejectedValue(new Error("unknown")) + }); + const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); + await expect(fetcher.getScheduled("target-1")).rejects.toThrow("unknown"); + }); + + it("listScheduled returns data and meta", async () => { + const entry = { + id: "schedule-1", + values: { + type: "publish", + title: "Test Entry", + targetId: "target-1", + dateOn: new Date().toISOString(), + scheduledBy: { id: "user-1" } + }, + savedOn: new Date().toISOString(), + savedBy: { id: "user-1" } + }; + const cms = createMockCms({ + listLatestEntries: vi.fn().mockResolvedValue([[entry], entryMeta]) + }); + const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); + const params: ISchedulerListParams = { + where: {}, + sort: undefined, + limit: undefined, + after: undefined + }; + const result = await fetcher.listScheduled(params); + expect(result.data.length).toBe(1); + expect(result.data[0].title).toBe("Test Entry"); + expect(result.meta).toEqual(entryMeta); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts b/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts new file mode 100644 index 00000000000..1a499fb8dbf --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts @@ -0,0 +1,68 @@ +import { Scheduler } from "~/scheduler/Scheduler.js"; +import { type ISchedulerInput, ScheduleType } from "~/scheduler/types.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Scheduler", () => { + const fetcher = { + getScheduled: vi.fn(), + listScheduled: vi.fn() + }; + const executor = { + schedule: vi.fn(), + cancel: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should create an instance of Scheduler", () => { + const result = new Scheduler({ fetcher, executor }); + + expect(result).toBeInstanceOf(Scheduler); + }); + + it("should call fetcher.getScheduled when fetching a schedule", async () => { + const scheduler = new Scheduler({ fetcher, executor }); + const targetId = "target-id#0001"; + const expectedResult = { id: "schedule-id#0001", targetId, dateOn: new Date() }; + fetcher.getScheduled.mockResolvedValue(expectedResult); + const result = await scheduler.getScheduled(targetId); + expect(fetcher.getScheduled).toHaveBeenCalledWith(targetId); + expect(result).toEqual(expectedResult); + }); + + it("should call fetcher.listScheduled when listing schedules", async () => { + const scheduler = new Scheduler({ fetcher, executor }); + const expectedResult = [ + { id: "schedule-id#0001", targetId: "target-id#0001", dateOn: new Date() } + ]; + fetcher.listScheduled.mockResolvedValue(expectedResult); + const result = await scheduler.listScheduled({ + sort: undefined, + after: undefined, + limit: undefined, + where: {} + }); + expect(fetcher.listScheduled).toHaveBeenCalled(); + expect(result).toEqual(expectedResult); + }); + + it("should call executor.schedule when scheduling a task", async () => { + const scheduler = new Scheduler({ fetcher, executor }); + const targetId = "target-id#0001"; + const input: ISchedulerInput = { + type: ScheduleType.publish, + scheduleOn: new Date() + }; + await scheduler.schedule(targetId, input); + expect(executor.schedule).toHaveBeenCalledWith(targetId, input); + }); + + it("should call executor.cancel when canceling a scheduled task", async () => { + const scheduler = new Scheduler({ fetcher, executor }); + const targetId = "target-id#0001"; + await scheduler.cancel(targetId); + expect(executor.cancel).toHaveBeenCalledWith(targetId); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts b/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts new file mode 100644 index 00000000000..11310ae0247 --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMockService } from "~tests/mocks/service.js"; +import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; +import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; +import { createMockCms } from "~tests/mocks/cms.js"; +import { createMockTargetModel } from "~tests/mocks/targetModel.js"; +import { PublishScheduleAction } from "~/scheduler/actions/PublishScheduleAction.js"; +import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; +import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; +import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; +import { + createScheduleRecordId, + createScheduleRecordIdWithVersion +} from "~/scheduler/createScheduleRecordId.js"; +import { dateToISOString } from "~/scheduler/dates.js"; +import { SchedulerService } from "~/service/SchedulerService.js"; +import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; +import { mockClient } from "aws-sdk-client-mock"; +import { createMockFetcher } from "~tests/mocks/fetcher"; + +describe("PublishScheduleAction", () => { + const service = createMockService(); + const fetcher = createMockFetcher(); + const getIdentity = createMockGetIdentity(); + const schedulerModel = createMockSchedulerModel(); + const targetModel = createMockTargetModel(); + + it("should schedule a publish action immediately", async () => { + const cms = createMockCms({ + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async publishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new PublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const result = await action.schedule({ + input: { + immediately: true, + type: ScheduleType.publish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: expect.any(Date), + unpublishOn: undefined, + type: ScheduleType.publish, + title: "Test Entry" + }); + }); + + it("should publish an entry immediately if the scheduleOn is in the past", async () => { + const updateEntryMock = vi.fn(async () => { + return {} as CmsEntry; + }); + const cms = createMockCms({ + updateEntry: updateEntryMock, + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async publishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new PublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const scheduleOn = new Date(Date.now() - 1000000); + const result = await action.schedule({ + input: { + scheduleOn, + type: ScheduleType.publish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: expect.any(Date), + unpublishOn: undefined, + dateOn: undefined, + type: ScheduleType.publish, + title: "Test Entry" + }); + + expect(updateEntryMock).toHaveBeenCalledTimes(1); + expect(updateEntryMock).toHaveBeenCalledWith(targetModel, "target-id#0002", { + firstPublishedBy: getIdentity(), + firstPublishedOn: scheduleOn.toISOString(), + lastPublishedBy: getIdentity(), + lastPublishedOn: scheduleOn.toISOString() + }); + }); + + it("should schedule a publish action for a future date", async () => { + const client = mockClient(SchedulerClient); + client.on(CreateScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }); + const service = new SchedulerService({ + getClient: () => client, + config: { + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-function", + roleArn: "arn:aws:iam::123456789012:role/my-role" + } + }); + const scheduleOn = new Date(Date.now() + 1000000); + + const crateEntryMock = vi.fn(async () => { + const entry: Pick, "id" | "values" | "savedBy"> = { + id: createScheduleRecordIdWithVersion(`target-id#0002`), + values: { + targetId: "target-id#0002", + type: ScheduleType.publish, + scheduledOn: dateToISOString(scheduleOn), + title: "Test Entry", + targetModelId: targetModel.modelId, + scheduledBy: getIdentity() + }, + savedBy: getIdentity() + }; + return entry as CmsEntry; + }); + const cms = createMockCms({ + // @ts-expect-error + createEntry: crateEntryMock, + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async publishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new PublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const result = await action.schedule({ + input: { + scheduleOn, + type: ScheduleType.publish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: scheduleOn, + unpublishOn: undefined, + type: ScheduleType.publish, + title: "Test Entry" + }); + + expect(crateEntryMock).toHaveBeenCalledTimes(1); + expect(crateEntryMock).toHaveBeenCalledWith(schedulerModel, { + id: createScheduleRecordId(`target-id#0002`), + scheduledBy: getIdentity(), + scheduledOn: dateToISOString(scheduleOn), + targetId: "target-id#0002", + targetModelId: "targetModel", + title: "Test Entry", + type: ScheduleType.publish + }); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts b/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts new file mode 100644 index 00000000000..e569654859c --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts @@ -0,0 +1,220 @@ +import { createMockService } from "~tests/mocks/service.js"; +import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; +import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; +import { createMockCms } from "~tests/mocks/cms.js"; +import { createMockTargetModel } from "~tests/mocks/targetModel.js"; +import { UnpublishScheduleAction } from "~/scheduler/actions/UnpublishScheduleAction.js"; +import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; +import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; +import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; +import { + createScheduleRecordId, + createScheduleRecordIdWithVersion +} from "~/scheduler/createScheduleRecordId.js"; +import { mockClient } from "aws-sdk-client-mock"; +import { dateToISOString } from "~/scheduler/dates.js"; +import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; +import { SchedulerService } from "~/service/SchedulerService.js"; +import { createMockFetcher } from "~tests/mocks/fetcher.js"; +import { describe, expect, it, vi } from "vitest"; + +describe("UnpublishScheduleAction", () => { + const service = createMockService(); + const fetcher = createMockFetcher(); + const getIdentity = createMockGetIdentity(); + const schedulerModel = createMockSchedulerModel(); + const targetModel = createMockTargetModel(); + + it("should unpublish an entry immediately if input.immediately is true", async () => { + const cms = createMockCms({ + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async unpublishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new UnpublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const result = await action.schedule({ + input: { + immediately: true, + type: ScheduleType.unpublish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: undefined, + unpublishOn: expect.any(Date), + type: ScheduleType.unpublish, + title: "Test Entry" + }); + }); + + it("should unpublish an entry immediately if the scheduleOn is in the past", async () => { + const cms = createMockCms({ + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async unpublishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new UnpublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const scheduleOn = new Date(Date.now() - 1000000); + const result = await action.schedule({ + input: { + scheduleOn, + type: ScheduleType.unpublish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: undefined, + unpublishOn: scheduleOn, + type: ScheduleType.unpublish, + title: "Test Entry" + }); + }); + + it("should schedule an unpublish action for a future date", async () => { + const client = mockClient(SchedulerClient); + client.on(CreateScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }); + + const service = new SchedulerService({ + getClient: () => client, + config: { + roleArn: "arn:aws:iam::123456789012:role/scheduler-role", + lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:scheduler-lambda" + } + }); + + const scheduleOn = new Date(Date.now() + 1000000); + + const createEntryMock = vi.fn(async () => { + const entry: Pick, "id" | "values" | "savedBy"> = { + id: createScheduleRecordIdWithVersion(`target-id#0002`), + values: { + targetId: "target-id#0002", + type: ScheduleType.unpublish, + scheduledOn: dateToISOString(scheduleOn), + title: "Test Entry", + targetModelId: targetModel.modelId, + scheduledBy: getIdentity() + }, + savedBy: getIdentity() + }; + return entry; + }); + const cms = createMockCms({ + // @ts-expect-error + createEntry: createEntryMock, + async getEntryById() { + return { + values: { + title: "Test Entry", + savedBy: getIdentity() + } + } as CmsEntry; + }, + async unpublishEntry() { + return { + savedBy: getIdentity(), + savedOn: new Date().toISOString() + } as CmsEntry; + } + }); + + const action = new UnpublishScheduleAction({ + service, + getIdentity, + targetModel, + schedulerModel, + cms, + fetcher + }); + + const result = await action.schedule({ + input: { + scheduleOn: scheduleOn, + type: ScheduleType.unpublish + }, + targetId: "target-id#0002", + scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) + }); + + expect(result).toBeInstanceOf(ScheduleRecord); + expect(result).toEqual({ + id: createScheduleRecordIdWithVersion(`target-id#0002`), + targetId: "target-id#0002", + model: targetModel, + scheduledBy: getIdentity(), + publishOn: undefined, + unpublishOn: scheduleOn, + type: ScheduleType.unpublish, + title: "Test Entry" + }); + + expect(createEntryMock).toHaveBeenCalledTimes(1); + expect(createEntryMock).toHaveBeenCalledWith(schedulerModel, { + id: createScheduleRecordId(`target-id#0002`), + scheduledOn: scheduleOn.toISOString(), + scheduledBy: getIdentity(), + targetId: "target-id#0002", + targetModelId: "targetModel", + title: "Test Entry", + type: ScheduleType.unpublish + }); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts b/packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts new file mode 100644 index 00000000000..857e51e0433 --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + createScheduleRecordId, + createScheduleRecordIdWithVersion +} from "~/scheduler/createScheduleRecordId.js"; +import { SCHEDULE_ID_PREFIX } from "~/constants.js"; + +describe("createScheduleRecordId", () => { + it("should create a valid schedule record ID with version", () => { + const result = createScheduleRecordIdWithVersion("target-id#0001"); + + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); + }); + + it("should create a valid schedule record ID from already created record ID with version", () => { + const result = createScheduleRecordIdWithVersion("target-id#0001"); + + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); + + const rerunResult = createScheduleRecordIdWithVersion(result); + + expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); + }); + + it("should create a valid schedule record ID without version", () => { + const result = createScheduleRecordId("target-id#0001"); + + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); + }); + + it("should create a valid schedule record ID from already created record ID without version", () => { + const result = createScheduleRecordId("target-id#0001"); + + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); + + const rerunResult = createScheduleRecordId(result); + + expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); + }); +}); diff --git a/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts b/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts new file mode 100644 index 00000000000..e3a6965e821 --- /dev/null +++ b/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createScheduler } from "~/scheduler/createScheduler.js"; +import { createMockService } from "~tests/mocks/service.js"; +import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; +import { createMockTargetModel } from "~tests/mocks/targetModel.js"; +import { createMockCms } from "~tests/mocks/cms.js"; +import { createMockSecurity } from "~tests/mocks/security.js"; +import { Scheduler } from "~/scheduler/Scheduler.js"; + +describe("createScheduler", () => { + const service = createMockService(); + const schedulerModel = createMockSchedulerModel(); + const targetModel = createMockTargetModel(); + const cms = createMockCms(); + const security = createMockSecurity(); + + it("should create a scheduler", async () => { + const result = await createScheduler({ + service, + cms, + security, + schedulerModel + }); + expect(result).toBeInstanceOf(Function); + + const scheduler = result(targetModel); + expect(scheduler).toHaveProperty("fetcher"); + expect(scheduler).toHaveProperty("executor"); + expect(scheduler).toBeInstanceOf(Scheduler); + }); +}); diff --git a/packages/api-scheduler/__tests__/service/SchedulerService.test.ts b/packages/api-scheduler/__tests__/service/SchedulerService.test.ts new file mode 100644 index 00000000000..66312e35d9a --- /dev/null +++ b/packages/api-scheduler/__tests__/service/SchedulerService.test.ts @@ -0,0 +1,220 @@ +import { SchedulerService } from "~/service/SchedulerService.js"; +import type { + ISchedulerServiceCreateInput, + ISchedulerServiceUpdateInput +} from "~/service/types.js"; +import { WebinyError } from "@webiny/error"; +import { mockClient } from "aws-sdk-client-mock"; +import { + CreateScheduleCommand, + DeleteScheduleCommand, + GetScheduleCommand, + SchedulerClient, + UpdateScheduleCommand +} from "@webiny/aws-sdk/client-scheduler/index.js"; +import { describe, expect, it, vi } from "vitest"; + +describe("SchedulerService", () => { + const lambdaArn = "arn:aws:lambda:us-east-1:123456789012:function:test"; + const roleArn = "arn:aws:iam::123456789012:role/test-role"; + const config = { + lambdaArn, + roleArn + }; + + it("creates a schedule successfully", async () => { + const client = mockClient(SchedulerClient); + client.on(CreateScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 999 + } + }); + + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const input: ISchedulerServiceCreateInput = { + id: "schedule-1", + scheduleOn: new Date(Date.now() + 1000000) + }; + + const result = await service.create(input); + expect(result).toEqual({ + $metadata: { + httpStatusCode: 999 + } + }); + }); + + it("throws if creating a schedule in the past", async () => { + const client = mockClient(SchedulerClient); + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const input: ISchedulerServiceCreateInput = { + id: "schedule-1", + scheduleOn: new Date(Date.now() - 100000) + }; + + try { + const result = await service.create(input); + expect(result).toEqual("SHOULD NOT REACH HERE"); + } catch (ex) { + expect(ex).toBeInstanceOf(WebinyError); + expect(ex.message).toContain( + `Cannot create a schedule for "schedule-1" with date in the past:` + ); + } + }); + + it("updates a schedule successfully", async () => { + const client = mockClient(SchedulerClient); + client.on(UpdateScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 999 + } + }); + client.on(GetScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }); + + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const input: ISchedulerServiceUpdateInput = { + id: "schedule-1", + scheduleOn: new Date(Date.now() + 1000000) + }; + + const result = await service.update(input); + + expect(result).toEqual({ + $metadata: { + httpStatusCode: 999 + } + }); + }); + + it("throws if updating a schedule in the past", async () => { + const client = mockClient(SchedulerClient); + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const input: ISchedulerServiceUpdateInput = { + id: "schedule-1", + scheduleOn: new Date(Date.now()) + }; + + try { + const result = await service.update(input); + expect(result).toEqual("SHOULD NOT REACH HERE"); + } catch (ex) { + expect(ex).toBeInstanceOf(WebinyError); + expect(ex.message).toContain( + `Cannot update an existing schedule for "schedule-1" with date in the past:` + ); + } + }); + + it("deletes a schedule successfully if it exists", async () => { + const client = mockClient(SchedulerClient); + + client.on(DeleteScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 999 + } + }); + + const service = new SchedulerService({ + getClient: () => client, + config + }); + vi.spyOn(service, "exists").mockResolvedValue(true); + + const result = await service.delete("schedule-1"); + + expect(result).toEqual({ + $metadata: { + httpStatusCode: 999 + } + }); + }); + + it("does not delete a schedule if it does not exist", async () => { + const client = mockClient(SchedulerClient); + const service = new SchedulerService({ + getClient: () => client, + config + }); + vi.spyOn(service, "exists").mockResolvedValue(false); + + try { + const result = await service.delete("schedule-1"); + expect(result).toEqual("SHOULD NOT REACH HERE"); + } catch (ex) { + expect(ex).toBeInstanceOf(WebinyError); + expect(ex.message).toContain( + `Cannot delete schedule "schedule-1" because it does not exist.` + ); + } + }); + + it("exists returns true if schedule is found", async () => { + const client = mockClient(SchedulerClient); + client.on(GetScheduleCommand).resolves({ + $metadata: { + httpStatusCode: 200 + } + }); + + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const result = await service.exists("schedule-1"); + + expect(result).toEqual(true); + }); + + it("exists returns false if ResourceNotFoundException is thrown", async () => { + const client = mockClient(SchedulerClient); + client.on(GetScheduleCommand).callsFake(async () => { + const error = new Error("Resource not found."); + error.name = "ResourceNotFoundException"; + throw error; + }); + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const result = await service.exists("schedule-1"); + expect(result).toBe(false); + }); + + it("throws on unknown error in exists", async () => { + const client = mockClient(SchedulerClient); + client.on(GetScheduleCommand).callsFake(async () => { + throw new Error("Unknown error."); + }); + const service = new SchedulerService({ + getClient: () => client, + config + }); + + const result = await service.exists("schedule-1"); + + expect(result).toEqual(false); + }); +}); diff --git a/packages/api-scheduler/src/constants.ts b/packages/api-scheduler/src/constants.ts new file mode 100644 index 00000000000..ae54b0d3a31 --- /dev/null +++ b/packages/api-scheduler/src/constants.ts @@ -0,0 +1,7 @@ +export const SCHEDULE_MODEL_ID = "webinyCmsSchedule"; +export const SCHEDULE_ID_PREFIX = "wby-schedule-"; +/** + * Minimum number of seconds in the future that a schedule can be set. + * Everything else will result in immediately running the action. + */ +export const SCHEDULE_MIN_FUTURE_SECONDS = 65; diff --git a/packages/api-scheduler/src/context.ts b/packages/api-scheduler/src/context.ts new file mode 100644 index 00000000000..6d8722e7e45 --- /dev/null +++ b/packages/api-scheduler/src/context.ts @@ -0,0 +1,48 @@ +import { ContextPlugin } from "@webiny/api"; +import type { + SchedulerClient, + SchedulerClientConfig +} from "@webiny/aws-sdk/client-scheduler/index.js"; +import { getManifest } from "~/manifest.js"; +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; +import { SCHEDULE_MODEL_ID } from "./constants.js"; +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; +import { EventBridgeSchedulerService } from "~/features/SchedulerService/EventBridgeSchedulerService.js"; +import { VoidSchedulerService } from "~/features/SchedulerService/VoidSchedulerService.js"; +import { createSchedulerModel } from "~/domain/model.js"; +import { SchedulerFeature } from "./features/SchedulerFeature.js"; + +export interface ICreateHeadlessCmsSchedulerContextParams { + getClient(config?: SchedulerClientConfig): Pick; +} + +export const createSchedulerContext = (params: ICreateHeadlessCmsSchedulerContextParams) => { + return new ContextPlugin(async context => { + const manifest = await getManifest({ + client: context.db.driver.getClient() as DynamoDBDocument + }); + + if (manifest.error) { + context.container.registerInstance(SchedulerService, new VoidSchedulerService()); + } else { + // TODO: in the future, extract AWS specific implementation into a separate package + context.container.registerInstance( + SchedulerService, + new EventBridgeSchedulerService(params.getClient, { + lambdaArn: manifest.data.lambdaArn, + roleArn: manifest.data.roleArn + }) + ); + } + + context.plugins.register(createSchedulerModel()); + const schedulerModel = await context.cms.getModel(SCHEDULE_MODEL_ID); + + // Register model via a dedicated abstraction + context.container.registerInstance(ScheduledActionModel, schedulerModel); + + // Register all features + SchedulerFeature.register(context.container); + }); +}; diff --git a/packages/api-scheduler/src/domain/errors.ts b/packages/api-scheduler/src/domain/errors.ts new file mode 100644 index 00000000000..1477a0e6650 --- /dev/null +++ b/packages/api-scheduler/src/domain/errors.ts @@ -0,0 +1,57 @@ +import { BaseError } from "@webiny/feature/api"; + +/** + * Scheduled action not found error + */ +export class ScheduledActionNotFoundError extends BaseError<{ scheduleId: string }> { + override readonly code = "Scheduler/ScheduledAction/NotFound" as const; + + constructor(scheduleId: string) { + super({ + message: `Scheduled action "${scheduleId}" was not found`, + data: { scheduleId } + }); + } +} + +/** + * Storage/persistence error when working with scheduled actions + */ +export class ScheduledActionPersistenceError extends BaseError<{ originalError: Error }> { + override readonly code = "Scheduler/ScheduledAction/PersistenceError" as const; + + constructor(error: Error) { + super({ + message: error.message, + data: { originalError: error } + }); + } +} + +/** + * Invalid schedule date error (e.g., scheduling in the past) + */ +export class InvalidScheduleDateError extends BaseError<{ scheduleOn: Date; scheduleId?: string }> { + override readonly code = "Scheduler/ScheduledAction/InvalidDate" as const; + + constructor(scheduleOn: Date, scheduleId?: string) { + super({ + message: "Cannot schedule in the past", + data: { scheduleOn, scheduleId } + }); + } +} + +/** + * Scheduler service error (EventBridge/cloud provider errors) + */ +export class SchedulerServiceError extends BaseError<{ originalError: Error }> { + override readonly code = "Scheduler/Service/Error" as const; + + constructor(error: Error) { + super({ + message: `Scheduler service error: ${error.message}`, + data: { originalError: error } + }); + } +} diff --git a/packages/api-scheduler/src/domain/model.ts b/packages/api-scheduler/src/domain/model.ts new file mode 100644 index 00000000000..b652e122959 --- /dev/null +++ b/packages/api-scheduler/src/domain/model.ts @@ -0,0 +1,93 @@ +import { createPrivateModelPlugin } from "@webiny/api-headless-cms/plugins/index.js"; +import { SCHEDULE_MODEL_ID } from "~/constants.js"; + +export const createSchedulerModel = () => { + return createPrivateModelPlugin({ + noValidate: true, + modelId: SCHEDULE_MODEL_ID, + name: "Webiny CMS Schedule", + fields: [ + { + id: "namespace", + fieldId: "namespace", + storageId: "text@namespace", + type: "text", + label: "Namespace" + }, + { + id: "actionType", + fieldId: "actionType", + storageId: "text@actionType", + type: "text", + label: "Action Type" + }, + { + id: "targetId", + fieldId: "targetId", + storageId: "text@targetId", + type: "text", + label: "Target ID" + }, + { + id: "scheduledBy", + fieldId: "scheduledBy", + storageId: "text@scheduledBy", + type: "object", + label: "Scheduled By", + settings: { + fields: [ + { + id: "id", + fieldId: "id", + storageId: "text@id", + type: "text", + label: "Identity ID" + }, + { + id: "displayName", + fieldId: "displayName", + storageId: "text@displayName", + type: "text", + label: "Display Name" + }, + { + id: "type", + fieldId: "type", + storageId: "text@type", + type: "text", + label: "Type" + } + ] + } + }, + { + id: "scheduledOn", + fieldId: "scheduledOn", + storageId: "date@scheduledOn", + type: "datetime", + label: "Scheduled On" + }, + { + id: "dateOn", + fieldId: "dateOn", + storageId: "date@dateOn", + type: "datetime", + label: "Date On" + }, + { + id: "title", + fieldId: "title", + storageId: "text@title", + type: "text", + label: "Title" + }, + { + id: "error", + fieldId: "error", + storageId: "text@error", + type: "text", + label: "Error" + } + ] + }); +}; diff --git a/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts b/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts new file mode 100644 index 00000000000..d9ab425a4bb --- /dev/null +++ b/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts @@ -0,0 +1,78 @@ +import { Result } from "@webiny/feature/api"; +import { CancelScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; +import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; +import { + ScheduledActionNotFoundError, + ScheduledActionPersistenceError, + SchedulerServiceError +} from "~/domain/errors.js"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; + +/** + * Cancels a scheduled action + * + * Flow: + * 1. Check if schedule exists + * 2. Delete EventBridge schedule + * 3. Delete CMS entry + * 4. If EventBridge delete fails, continue anyway (schedule might already be executed/deleted) + */ +class CancelScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getScheduledActionUseCase: GetScheduledActionUseCase.Interface, + private schedulerService: SchedulerService.Interface, + private deleteEntryUseCase: DeleteEntryUseCase.Interface, + private model: ScheduledActionModel.Interface + ) {} + + async execute(scheduleId: string): Promise> { + // Check if schedule exists + const getResult = await this.getScheduledActionUseCase.execute(scheduleId); + + if (getResult.isFail()) { + const error = getResult.error; + + if (error.code === "Scheduler/ScheduledAction/NotFound") { + return Result.fail(new ScheduledActionNotFoundError(scheduleId)); + } + + return Result.fail(error); + } + + // Delete EventBridge schedule + // Note: We continue even if this fails, as the schedule might already be executed/deleted + try { + await this.schedulerService.delete(scheduleId); + } catch (error) { + console.warn( + `Failed to delete EventBridge schedule: ${scheduleId}. Continuing with CMS entry deletion.`, + error + ); + } + + // Delete CMS entry + const deleteResult = await this.deleteEntryUseCase.execute(this.model, scheduleId, { + force: true, + permanently: true + }); + + if (deleteResult.isFail()) { + return Result.fail( + new ScheduledActionPersistenceError(new Error(deleteResult.error.message)) + ); + } + + return Result.ok(undefined); + } +} + +export const CancelScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: CancelScheduledActionUseCaseImpl, + dependencies: [ + GetScheduledActionUseCase, + SchedulerService, + DeleteEntryUseCase, + ScheduledActionModel + ] +}); diff --git a/packages/api-scheduler/src/features/CancelScheduledAction/abstractions.ts b/packages/api-scheduler/src/features/CancelScheduledAction/abstractions.ts new file mode 100644 index 00000000000..653ac6c8b75 --- /dev/null +++ b/packages/api-scheduler/src/features/CancelScheduledAction/abstractions.ts @@ -0,0 +1,36 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { + ScheduledActionNotFoundError, + ScheduledActionPersistenceError, + SchedulerServiceError +} from "~/domain/errors.js"; + +/** + * CancelScheduledActionUseCase - Cancel a scheduled action + * + * Cancels both the CMS entry and the EventBridge schedule. + * Used when a user manually cancels a scheduled action or when business logic + * determines the action should no longer execute. + */ + +export interface ICancelScheduledActionErrors { + notFound: ScheduledActionNotFoundError; + persistence: ScheduledActionPersistenceError; + schedulerService: SchedulerServiceError; +} + +type CancelScheduledActionError = ICancelScheduledActionErrors[keyof ICancelScheduledActionErrors]; + +export interface ICancelScheduledActionUseCase { + execute(scheduleId: string): Promise>; +} + +export const CancelScheduledActionUseCase = createAbstraction( + "CancelScheduledActionUseCase" +); + +export namespace CancelScheduledActionUseCase { + export type Interface = ICancelScheduledActionUseCase; + export type Error = CancelScheduledActionError; +} diff --git a/packages/api-scheduler/src/features/CancelScheduledAction/feature.ts b/packages/api-scheduler/src/features/CancelScheduledAction/feature.ts new file mode 100644 index 00000000000..2e5996502e0 --- /dev/null +++ b/packages/api-scheduler/src/features/CancelScheduledAction/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { CancelScheduledActionUseCase } from "./CancelScheduledActionUseCase.js"; + +/** + * CancelScheduledAction Feature + * + * Provides the ability to cancel a scheduled action. + * Removes both the EventBridge schedule and the CMS entry. + */ +export const CancelScheduledActionFeature = createFeature({ + name: "CancelScheduledAction", + register(container) { + container.register(CancelScheduledActionUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/CancelScheduledAction/index.ts b/packages/api-scheduler/src/features/CancelScheduledAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/CancelScheduledAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts new file mode 100644 index 00000000000..62ac80fc6c2 --- /dev/null +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts @@ -0,0 +1,112 @@ +import { Result } from "@webiny/feature/api"; +import { + ExecuteScheduledActionUseCase as UseCaseAbstraction, + HandlerNotFoundError, + ExecutionFailedError +} from "./abstractions.js"; +import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; +import { ScheduledActionHandler, ScheduledActionModel } from "~/shared/abstractions.js"; +import { + ScheduledActionNotFoundError, + ScheduledActionPersistenceError +} from "~/domain/errors.js"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; + +/** + * Executes a scheduled action + * + * Flow: + * 1. Load scheduled action from CMS + * 2. Find registered handler for namespace + actionType + * 3. Execute handler + * 4. Delete schedule entry on success + * 5. Update entry with error on failure (for debugging/audit) + */ +class ExecuteScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getScheduledActionUseCase: GetScheduledActionUseCase.Interface, + private actionHandler: ScheduledActionHandler.Interface, + private deleteEntryUseCase: DeleteEntryUseCase.Interface, + private updateEntryUseCase: UpdateEntryUseCase.Interface, + private model: ScheduledActionModel.Interface + ) {} + + async execute(scheduleId: string): Promise> { + // Load scheduled action + const getResult = await this.getScheduledActionUseCase.execute(scheduleId); + + if (getResult.isFail()) { + const error = getResult.error; + + if (error.code === "Scheduler/ScheduledAction/NotFound") { + return Result.fail(new ScheduledActionNotFoundError(scheduleId)); + } + + return Result.fail(error); + } + + const scheduledAction = getResult.value; + + // Check if handler can handle this action + if (!this.actionHandler.canHandle(scheduledAction.namespace, scheduledAction.actionType)) { + const error = new HandlerNotFoundError( + scheduledAction.namespace, + scheduledAction.actionType + ); + + // Update entry with error for debugging + await this.updateEntryUseCase.execute(this.model, scheduleId, { + error: error.message + }); + + return Result.fail(error); + } + + // Execute handler + try { + await this.actionHandler.handle(scheduledAction); + + // Delete schedule entry on success + const deleteResult = await this.deleteEntryUseCase.execute( + this.model, + scheduleId, + { + force: true, + permanently: true + } + ); + + if (deleteResult.isFail()) { + return Result.fail( + new ScheduledActionPersistenceError(new Error(deleteResult.error.message)) + ); + } + + return Result.ok(undefined); + } catch (error) { + const executionError = new ExecutionFailedError( + `Failed to execute scheduled action: ${(error as Error).message}`, + error as Error + ); + + // Update entry with error for debugging + await this.updateEntryUseCase.execute(this.model, scheduleId, { + error: executionError.message + }); + + return Result.fail(executionError); + } + } +} + +export const ExecuteScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: ExecuteScheduledActionUseCaseImpl, + dependencies: [ + GetScheduledActionUseCase, + ScheduledActionHandler, + DeleteEntryUseCase, + UpdateEntryUseCase, + ScheduledActionModel + ] +}); diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/ScheduledActionHandlerComposite.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/ScheduledActionHandlerComposite.ts new file mode 100644 index 00000000000..eb534d551d5 --- /dev/null +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/ScheduledActionHandlerComposite.ts @@ -0,0 +1,34 @@ +import { IScheduledAction, ScheduledActionHandler } from "~/shared/abstractions.js"; + +/** + * Composite handler that iterates through all registered handlers + * to find one that can handle the given namespace + actionType combination. + * + * This is registered as a composite via container.registerComposite(), + * and the DI container will automatically inject all registered handlers. + */ +class ScheduledActionHandlerCompositeImpl implements ScheduledActionHandler.Interface { + constructor(private handlers: ScheduledActionHandler.Interface[]) {} + + canHandle(namespace: string, actionType: string): boolean { + return this.handlers.some(handler => handler.canHandle(namespace, actionType)); + } + + async handle(action: IScheduledAction): Promise { + const handler = this.handlers.find(h => h.canHandle(action.namespace, action.actionType)); + + if (!handler) { + console.log( + `No handler found for namespace "${action.namespace}" and actionType "${action.actionType}"` + ); + return; + } + + await handler.handle(action); + } +} + +export const ScheduledActionHandlerComposite = ScheduledActionHandler.createComposite({ + implementation: ScheduledActionHandlerCompositeImpl, + dependencies: [[ScheduledActionHandler, { multiple: true }]] +}); diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts new file mode 100644 index 00000000000..701d2869300 --- /dev/null +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { + ScheduledActionNotFoundError, + ScheduledActionPersistenceError +} from "~/domain/errors.js"; + +/** + * ExecuteScheduledActionUseCase - Execute a scheduled action + * + * This is triggered by EventBridge when a schedule fires. + * Finds the appropriate handler based on namespace + actionType and executes it. + * + * Flow: + * 1. Load scheduled action from CMS + * 2. Find registered handler for namespace + actionType + * 3. Execute handler + * 4. Delete schedule entry on success + * 5. Update entry with error on failure + */ + +export interface IExecuteScheduledActionErrors { + notFound: ScheduledActionNotFoundError; + persistence: ScheduledActionPersistenceError; + handlerNotFound: HandlerNotFoundError; + executionFailed: ExecutionFailedError; +} + +type ExecuteScheduledActionError = IExecuteScheduledActionErrors[keyof IExecuteScheduledActionErrors]; + +export interface IExecuteScheduledActionUseCase { + execute(scheduleId: string): Promise>; +} + +export const ExecuteScheduledActionUseCase = createAbstraction( + "ExecuteScheduledActionUseCase" +); + +export namespace ExecuteScheduledActionUseCase { + export type Interface = IExecuteScheduledActionUseCase; + export type Error = ExecuteScheduledActionError; +} + +/** + * Handler not found error + */ +export class HandlerNotFoundError extends Error { + readonly code = "Scheduler/Handler/NotFound" as const; + + constructor(namespace: string, actionType: string) { + super( + `No handler registered for namespace "${namespace}" and actionType "${actionType}"` + ); + this.name = "HandlerNotFoundError"; + } +} + +/** + * Execution failed error + */ +export class ExecutionFailedError extends Error { + readonly code = "Scheduler/Execution/Failed" as const; + + constructor(message: string, public readonly originalError?: Error) { + super(message); + this.name = "ExecutionFailedError"; + } +} diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts new file mode 100644 index 00000000000..1c932168616 --- /dev/null +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { ExecuteScheduledActionUseCase } from "./ExecuteScheduledActionUseCase.js"; + +/** + * ExecuteScheduledAction Feature + * + * Provides the ability to execute a scheduled action when triggered by EventBridge. + * Finds the appropriate handler and executes it, then cleans up the schedule entry. + */ +export const ExecuteScheduledActionFeature = createFeature({ + name: "ExecuteScheduledAction", + register(container) { + container.register(ExecuteScheduledActionUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/index.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts new file mode 100644 index 00000000000..94845dad062 --- /dev/null +++ b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts @@ -0,0 +1,52 @@ +import { Result } from "@webiny/feature/api"; +import { GetScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import type { IScheduledAction } from "~/shared/abstractions.js"; +import { ScheduledActionModel } from "~/shared/abstractions.js"; +import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; + +/** + * Retrieves a scheduled action by its ID + * + * Flow: + * 1. Fetch schedule entry from CMS storage by ID + * 2. Return null if not found + * 3. Transform CMS entry to IScheduledAction format + */ +class GetScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getEntryByIdUseCase: GetEntryByIdUseCase.Interface, + private model: ScheduledActionModel.Interface + ) {} + + async execute(scheduleId: string): Promise> { + // Get entry from CMS + const entryResult = await this.getEntryByIdUseCase.execute(this.model, scheduleId); + + if (entryResult.isFail()) { + if (entryResult.error.code === "Cms/Entry/NotFound") { + return Result.fail(new ScheduledActionNotFoundError(scheduleId)); + } + + return Result.fail(new ScheduledActionPersistenceError(entryResult.error)); + } + + const entry = entryResult.value; + + return Result.ok({ + id: entry.id, + namespace: entry.values.namespace, + actionType: entry.values.actionType, + targetId: entry.values.targetId, + scheduledBy: entry.values.scheduledBy, + scheduledOn: new Date(entry.values.scheduledOn), + payload: entry.values.payload, + error: entry.values.error + }); + } +} + +export const GetScheduledActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetScheduledActionUseCaseImpl, + dependencies: [GetEntryByIdUseCase, ScheduledActionModel] +}); diff --git a/packages/api-scheduler/src/features/GetScheduledAction/abstractions.ts b/packages/api-scheduler/src/features/GetScheduledAction/abstractions.ts new file mode 100644 index 00000000000..2efec8d74c3 --- /dev/null +++ b/packages/api-scheduler/src/features/GetScheduledAction/abstractions.ts @@ -0,0 +1,33 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { IScheduledAction } from "~/shared/abstractions.js"; +import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; + +/** + * GetScheduledActionUseCase - Retrieve a scheduled action by ID + * + * Used to check if a schedule exists (for reschedule logic) and to retrieve + * schedule details for display/management purposes. + * + * Returns null value (Result.ok(null)) if schedule not found. + */ + +export interface IGetScheduledActionErrors { + persistence: ScheduledActionPersistenceError; + notFound: ScheduledActionNotFoundError; +} + +type GetScheduledActionError = IGetScheduledActionErrors[keyof IGetScheduledActionErrors]; + +export interface IGetScheduledActionUseCase { + execute(scheduleId: string): Promise>; +} + +export const GetScheduledActionUseCase = createAbstraction( + "GetScheduledActionUseCase" +); + +export namespace GetScheduledActionUseCase { + export type Interface = IGetScheduledActionUseCase; + export type Error = GetScheduledActionError; +} diff --git a/packages/api-scheduler/src/features/GetScheduledAction/feature.ts b/packages/api-scheduler/src/features/GetScheduledAction/feature.ts new file mode 100644 index 00000000000..6524e81c6e3 --- /dev/null +++ b/packages/api-scheduler/src/features/GetScheduledAction/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetScheduledActionUseCase } from "./GetScheduledActionUseCase.js"; + +/** + * GetScheduledAction Feature + * + * Provides the ability to retrieve a scheduled action by ID. + * Used for checking if schedules exist and displaying schedule details. + */ +export const GetScheduledActionFeature = createFeature({ + name: "GetScheduledAction", + register(container) { + container.register(GetScheduledActionUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/GetScheduledAction/index.ts b/packages/api-scheduler/src/features/GetScheduledAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/GetScheduledAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts new file mode 100644 index 00000000000..fb43fcdee0c --- /dev/null +++ b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts @@ -0,0 +1,69 @@ +import { Result } from "@webiny/feature/api"; +import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/abstractions.js"; +import type { CmsEntryListWhere } from "@webiny/api-headless-cms/types/index.js"; +import { + ListScheduledActionsUseCase as UseCaseAbstraction, + IListScheduledActionsParams, + IListScheduledActionsResponse +} from "./abstractions.js"; +import type { IScheduledAction } from "~/shared/abstractions.js"; +import { ScheduledActionModel } from "~/shared/abstractions.js"; +import { ScheduledActionPersistenceError } from "~/domain/errors.js"; + +/** + * Lists scheduled actions with optional filtering + * + * Flow: + * 1. Build query filters based on where params (namespace, actionType, targetId, etc.) + * 2. Fetch entries from CMS storage with pagination and sorting + * 3. Transform CMS entries to IScheduledAction format + * 4. Return paginated results with metadata + */ +class ListScheduledActionsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private listEntriesUseCase: ListEntriesUseCase.Interface, + private model: ScheduledActionModel.Interface + ) {} + + async execute( + params: IListScheduledActionsParams + ): Promise> { + const { where, sort, limit, after } = params; + + // List entries from CMS + const listResult = await this.listEntriesUseCase.execute(this.model, { + where: where as CmsEntryListWhere, + sort, + limit, + after + }); + + if (listResult.isFail()) { + return Result.fail(new ScheduledActionPersistenceError(listResult.error)); + } + + const [items, meta] = listResult.value; + + // Transform entries to IScheduledAction format + const scheduledActions: IScheduledAction[] = items.map(entry => ({ + id: entry.id, + namespace: entry.values.namespace, + actionType: entry.values.actionType, + targetId: entry.values.targetId, + scheduledBy: entry.values.scheduledBy, + scheduledOn: new Date(entry.values.scheduledOn), + payload: entry.values.payload, + error: entry.values.error + })); + + return Result.ok({ + items: scheduledActions, + meta + }); + } +} + +export const ListScheduledActionsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListScheduledActionsUseCaseImpl, + dependencies: [ListEntriesUseCase, ScheduledActionModel] +}); diff --git a/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts b/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts new file mode 100644 index 00000000000..08a0dbf231f --- /dev/null +++ b/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts @@ -0,0 +1,61 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { IScheduledAction } from "~/shared/abstractions.js"; +import { ScheduledActionPersistenceError } from "~/domain/errors.js"; +import type { CmsEntryListSort, CmsEntryMeta } from "@webiny/api-headless-cms/types/index.js"; + +/** + * ListScheduledActionsUseCase - List scheduled actions with optional filtering + * + * Used to retrieve all scheduled actions for a namespace (e.g., all actions for Article model) + * or all actions of a specific type across namespaces. + * + * This is critical for CMS CRUD views where we need to show ALL scheduled actions + * (publish, unpublish, delete) for a specific content model. + */ + +export type DateISOString = + `${number}-${number}-${number}T${number}:${number}:${number}.${number}Z`; + +export interface IListScheduledActionsWhere { + namespace?: string; + actionType?: string; + targetId?: string; + scheduledBy?: string; + scheduledOn?: DateISOString; + scheduledOn_gte?: DateISOString; + scheduledOn_lte?: DateISOString; +} + +export interface IListScheduledActionsParams { + where: IListScheduledActionsWhere; + sort?: CmsEntryListSort; + limit?: number; + after?: string; +} + +export interface IListScheduledActionsResponse { + items: IScheduledAction[]; + meta: CmsEntryMeta; +} + +export interface IListScheduledActionsErrors { + persistence: ScheduledActionPersistenceError; +} + +type ListScheduledActionsError = IListScheduledActionsErrors[keyof IListScheduledActionsErrors]; + +export interface IListScheduledActionsUseCase { + execute( + params: IListScheduledActionsParams + ): Promise>; +} + +export const ListScheduledActionsUseCase = createAbstraction( + "ListScheduledActionsUseCase" +); + +export namespace ListScheduledActionsUseCase { + export type Interface = IListScheduledActionsUseCase; + export type Error = ListScheduledActionsError; +} diff --git a/packages/api-scheduler/src/features/ListScheduledActions/feature.ts b/packages/api-scheduler/src/features/ListScheduledActions/feature.ts new file mode 100644 index 00000000000..8777b934407 --- /dev/null +++ b/packages/api-scheduler/src/features/ListScheduledActions/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListScheduledActionsUseCase } from "./ListScheduledActionsUseCase.js"; + +/** + * ListScheduledActions Feature + * + * Provides the ability to list scheduled actions with optional filtering by namespace or actionType. + * Critical for CMS CRUD views showing all scheduled actions for a content model. + */ +export const ListScheduledActionsFeature = createFeature({ + name: "ListScheduledActions", + register(container) { + container.register(ListScheduledActionsUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/ListScheduledActions/index.ts b/packages/api-scheduler/src/features/ListScheduledActions/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/ListScheduledActions/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts new file mode 100644 index 00000000000..922a90fd8de --- /dev/null +++ b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts @@ -0,0 +1,216 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; +import { ScheduleActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; +import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; +import type { IScheduledAction, ISchedulerInput, Identity } from "~/shared/abstractions.js"; +import { ScheduledActionPersistenceError, SchedulerServiceError } from "~/domain/errors.js"; + +/** + * Schedules an action for future execution + * + * Flow: + * 1. Generate unique schedule ID from namespace+actionType+targetId + * 2. Check if schedule already exists (for rescheduling logic) + * 3. If exists: UPDATE schedule entry + EventBridge schedule + * 4. If new: CREATE schedule entry + EventBridge schedule + * 5. Rollback schedule entry if EventBridge fails + * + * Note: Does NOT handle immediate execution - apps use direct use cases for that + */ +class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private identityContext: IdentityContext.Interface, + private model: ScheduledActionModel.Interface, + private schedulerService: SchedulerService.Interface, + private getScheduledAction: GetScheduledActionUseCase.Interface, + private createEntryUseCase: CreateEntryUseCase.Interface, + private updateEntryUseCase: UpdateEntryUseCase.Interface, + private deleteEntryUseCase: DeleteEntryUseCase.Interface + ) {} + + async execute( + namespace: string, + actionType: string, + targetId: string, + input: ISchedulerInput, + payload?: any + ): Promise> { + const identity = this.identityContext.getIdentity(); + + // Generate unique schedule ID + const scheduleId = this.generateScheduleId(namespace, actionType, targetId); + + const existingResult = await this.getScheduledAction.execute(scheduleId); + + if (existingResult.isFail()) { + const error = existingResult.error; + + // NotFound means the action was not yet scheduled + if (error.code === "Scheduler/ScheduledAction/NotFound") { + return this.createSchedule( + scheduleId, + namespace, + actionType, + targetId, + input, + identity, + payload + ); + } + + if (error.code === "Scheduler/ScheduledAction/PersistenceError") { + return Result.fail(error); + } + } + + // Reschedule existing action + const scheduledAction = existingResult.value; + + return this.reschedule(scheduledAction, input, identity, payload); + } + + /** + * Creates a new schedule + */ + private async createSchedule( + scheduleId: string, + namespace: string, + actionType: string, + targetId: string, + input: ISchedulerInput, + identity: Identity, + payload?: any + ): Promise> { + const scheduledAction: IScheduledAction = { + id: scheduleId, + namespace, + actionType, + targetId, + scheduledBy: identity, + scheduledOn: input.scheduleOn, + payload + }; + + // Create CMS entry + const createResult = await this.createEntryUseCase.execute(this.model, { + id: scheduleId, + namespace, + actionType, + targetId, + scheduledBy: identity, + scheduledOn: input.scheduleOn.toISOString(), + payload + }); + + if (createResult.isFail()) { + return Result.fail( + new ScheduledActionPersistenceError(new Error(createResult.error.message)) + ); + } + + // Create EventBridge schedule + try { + await this.schedulerService.create({ + id: scheduleId, + scheduleOn: input.scheduleOn, + payload: { + ScheduledAction: { + id: scheduleId, + namespace, + actionType, + targetId + } + } + }); + } catch (error) { + // Rollback - delete CMS entry if EventBridge fails + console.error(`Failed to create EventBridge schedule: ${scheduleId}. Rolling back...`); + + await this.deleteEntryUseCase.execute(this.model, scheduleId, { + force: true, + permanently: true + }); + + return Result.fail(new SchedulerServiceError(error as Error)); + } + + return Result.ok(scheduledAction); + } + + /** + * Updates an existing schedule (reschedule) + */ + private async reschedule( + existing: IScheduledAction, + input: ISchedulerInput, + identity: Identity, + payload?: any + ): Promise> { + // Update CMS entry + const updateResult = await this.updateEntryUseCase.execute(this.model, existing.id, { + scheduledBy: identity, + scheduledOn: input.scheduleOn.toISOString(), + payload + }); + + if (updateResult.isFail()) { + return Result.fail( + new ScheduledActionPersistenceError(new Error(updateResult.error.message)) + ); + } + + // Update EventBridge schedule + try { + await this.schedulerService.update({ + id: existing.id, + scheduleOn: input.scheduleOn, + payload: { + ScheduledAction: { + id: existing.id, + namespace: existing.namespace, + actionType: existing.actionType, + targetId: existing.targetId + } + } + }); + } catch (error) { + return Result.fail(new SchedulerServiceError(error as Error)); + } + + return Result.ok({ + ...existing, + scheduledBy: identity, + scheduledOn: input.scheduleOn, + payload + }); + } + + /** + * Generates a unique schedule ID from namespace + actionType + targetId + * + * Format: "namespace-actionType-targetId" with special chars replaced + * Example: "Cms_Entry_Article-Publish-article-1#0001" + */ + private generateScheduleId(namespace: string, actionType: string, targetId: string): string { + // TODO: is this really necessary? + const namespaceNormalized = namespace.replace(/\//g, "_"); + return `${namespaceNormalized}-${actionType}-${targetId}`; + } +} + +export const ScheduleActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: ScheduleActionUseCaseImpl, + dependencies: [ + IdentityContext, + ScheduledActionModel, + GetScheduledActionUseCase, + SchedulerService, + CreateEntryUseCase, + UpdateEntryUseCase, + DeleteEntryUseCase + ] +}); diff --git a/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts b/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts new file mode 100644 index 00000000000..5905e7e4e2d --- /dev/null +++ b/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts @@ -0,0 +1,43 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { IScheduledAction, ISchedulerInput } from "~/shared/abstractions.js"; +import { + ScheduledActionPersistenceError, + InvalidScheduleDateError, + SchedulerServiceError +} from "~/domain/errors.js"; + +/** + * ScheduleActionUseCase - Schedule an action for future execution + * + * Handles both new schedules and rescheduling (update) automatically: + * - If no schedule exists for the namespace+actionType+targetId combination: creates new schedule + * - If schedule already exists: updates the existing schedule (reschedule) + */ + +export interface IScheduleActionErrors { + persistence: ScheduledActionPersistenceError; + invalidDate: InvalidScheduleDateError; + schedulerService: SchedulerServiceError; +} + +type ScheduleActionError = IScheduleActionErrors[keyof IScheduleActionErrors]; + +export interface IScheduleActionUseCase { + execute( + namespace: string, + actionType: string, + targetId: string, + input: ISchedulerInput, + payload?: any + ): Promise>; +} + +export const ScheduleActionUseCase = createAbstraction( + "ScheduleActionUseCase" +); + +export namespace ScheduleActionUseCase { + export type Interface = IScheduleActionUseCase; + export type Error = ScheduleActionError; +} diff --git a/packages/api-scheduler/src/features/ScheduleAction/feature.ts b/packages/api-scheduler/src/features/ScheduleAction/feature.ts new file mode 100644 index 00000000000..7a1964201ad --- /dev/null +++ b/packages/api-scheduler/src/features/ScheduleAction/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { ScheduleActionUseCase } from "./ScheduleActionUseCase.js"; + +/** + * ScheduleAction Feature + * + * Provides the ability to schedule actions for future execution. + * Handles both creating new schedules and updating existing ones (reschedule). + */ +export const ScheduleActionFeature = createFeature({ + name: "ScheduleAction", + register(container) { + container.register(ScheduleActionUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/ScheduleAction/index.ts b/packages/api-scheduler/src/features/ScheduleAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/ScheduleAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/SchedulerFeature.ts b/packages/api-scheduler/src/features/SchedulerFeature.ts new file mode 100644 index 00000000000..71c6a370800 --- /dev/null +++ b/packages/api-scheduler/src/features/SchedulerFeature.ts @@ -0,0 +1,29 @@ +import { createFeature } from "@webiny/feature/api"; +import { ScheduleActionFeature } from "./ScheduleAction/feature.js"; +import { GetScheduledActionFeature } from "./GetScheduledAction/feature.js"; +import { ListScheduledActionsFeature } from "./ListScheduledActions/feature.js"; +import { CancelScheduledActionFeature } from "./CancelScheduledAction/feature.js"; +import { ExecuteScheduledActionFeature } from "./ExecuteScheduledAction/feature.js"; +import { ScheduledActionHandlerComposite } from "./ExecuteScheduledAction/ScheduledActionHandlerComposite.js"; + +/** + * Main Scheduler Feature + * + * Registers all scheduler use cases and the composite handler. + * Individual handler implementations are registered by consumer packages + * (e.g., api-headless-cms-scheduler registers CMS-specific handlers). + */ +export const SchedulerFeature = createFeature({ + name: "Scheduler", + register(container) { + // Register the composite handler + container.registerComposite(ScheduledActionHandlerComposite); + + // Register all features + ScheduleActionFeature.register(container); + GetScheduledActionFeature.register(container); + ListScheduledActionsFeature.register(container); + CancelScheduledActionFeature.register(container); + ExecuteScheduledActionFeature.register(container); + } +}); diff --git a/packages/api-scheduler/src/EventBridgeSchedulerService.ts b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts similarity index 89% rename from packages/api-scheduler/src/EventBridgeSchedulerService.ts rename to packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts index 6180b4e10a4..320d64a581b 100644 --- a/packages/api-scheduler/src/EventBridgeSchedulerService.ts +++ b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts @@ -1,5 +1,5 @@ import { WebinyError } from "@webiny/error"; -import { SchedulerService } from "./abstractions.js"; +import { SchedulerService } from "~/shared/abstractions.js"; import { CreateScheduleCommand, UpdateScheduleCommand, @@ -21,9 +21,9 @@ export interface ISchedulerConfig { * Manages schedules in AWS EventBridge Scheduler for triggering Lambda functions * at specified future times. */ -class EventBridgeSchedulerServiceImpl implements SchedulerService.Interface { +export class EventBridgeSchedulerService implements SchedulerService.Interface { constructor( - private getClient: (config?: any) => SchedulerClient, + private getClient: (config?: any) => Pick, private config: ISchedulerConfig ) {} @@ -124,9 +124,3 @@ class EventBridgeSchedulerServiceImpl implements SchedulerService.Interface { } } } - -export const EventBridgeSchedulerService = SchedulerService.createImplementation({ - implementation: EventBridgeSchedulerServiceImpl, - // Dependencies will be registered as instances in context - dependencies: [] -}); diff --git a/packages/api-scheduler/src/features/SchedulerService/VoidSchedulerService.ts b/packages/api-scheduler/src/features/SchedulerService/VoidSchedulerService.ts new file mode 100644 index 00000000000..7bdebcf163e --- /dev/null +++ b/packages/api-scheduler/src/features/SchedulerService/VoidSchedulerService.ts @@ -0,0 +1,19 @@ +import { SchedulerService } from "~/shared/abstractions.js"; + +export class VoidSchedulerService implements SchedulerService.Interface { + async create(): Promise { + // Do nothing. + } + + async update(): Promise { + // Do nothing. + } + + async delete(): Promise { + // Do nothing. + } + + async exists(): Promise { + return false; + } +} diff --git a/packages/api-scheduler/src/index.ts b/packages/api-scheduler/src/index.ts index 4a028de578e..6e0f1c6d8ad 100644 --- a/packages/api-scheduler/src/index.ts +++ b/packages/api-scheduler/src/index.ts @@ -1,9 +1,13 @@ // Shared abstractions -export * from "./abstractions.js"; +export * from "./shared/abstractions.js"; +export * from "./domain/errors.js"; -// Features will be exported here as they're implemented -// export * from "./features/ScheduleAction/index.js"; -// export * from "./features/GetScheduledAction/index.js"; -// export * from "./features/ListScheduledActions/index.js"; -// export * from "./features/CancelScheduledAction/index.js"; -// export * from "./features/ExecuteScheduledAction/index.js"; +// Main feature +export { SchedulerFeature } from "./SchedulerFeature.js"; + +// Feature abstractions (for use case dependencies) +export * from "./features/ScheduleAction/index.js"; +export * from "./features/GetScheduledAction/index.js"; +export * from "./features/ListScheduledActions/index.js"; +export * from "./features/CancelScheduledAction/index.js"; +export * from "./features/ExecuteScheduledAction/index.js"; diff --git a/packages/api-scheduler/src/manifest.ts b/packages/api-scheduler/src/manifest.ts new file mode 100644 index 00000000000..2ba1e371ab0 --- /dev/null +++ b/packages/api-scheduler/src/manifest.ts @@ -0,0 +1,61 @@ +import { ServiceDiscovery } from "@webiny/api"; +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; +import { createZodError } from "@webiny/utils"; +import zod from "zod"; + +const schema = zod.object({ + scheduler: zod.object({ + lambdaArn: zod.string(), + roleArn: zod.string() + }) +}); + +export interface IGetManifestErrorResult { + error: Error; + data?: never; +} + +export interface IGetManifestSuccessResult { + data: { + lambdaArn: string; + roleArn: string; + }; + error?: never; +} + +export type IGetManifestResult = IGetManifestSuccessResult | IGetManifestErrorResult; + +export interface IGetManifestParams { + client: DynamoDBDocument; +} + +export const getManifest = async (params: IGetManifestParams): Promise => { + try { + ServiceDiscovery.setDocumentClient(params.client); + const manifest = await ServiceDiscovery.load(); + if (!manifest) { + return { + error: new Error("Manifest could not be loaded.") + }; + } else if (!manifest.scheduler) { + return { + error: new Error("Scheduler not found in the Manifest.") + }; + } + + const result = await schema.safeParseAsync(manifest); + if (!result.success) { + return { + error: createZodError(result.error) + }; + } + + return { + data: result.data.scheduler + }; + } catch (ex) { + return { + error: ex + }; + } +}; diff --git a/packages/api-scheduler/src/abstractions.ts b/packages/api-scheduler/src/shared/abstractions.ts similarity index 60% rename from packages/api-scheduler/src/abstractions.ts rename to packages/api-scheduler/src/shared/abstractions.ts index ae79553f598..c282f34c649 100644 --- a/packages/api-scheduler/src/abstractions.ts +++ b/packages/api-scheduler/src/shared/abstractions.ts @@ -1,3 +1,4 @@ +import type { CmsModel } from "@webiny/api-headless-cms/types/model.js"; import { createAbstraction } from "@webiny/feature/api"; /** @@ -14,46 +15,20 @@ export interface Identity { */ export interface IScheduledAction { id: string; - namespace: string; // Resource scope: "Cms/Entry/Article", "Mailer/Email" - actionType: string; // Operation: "Publish", "Unpublish", "Send", "Delete" - targetId: string; // Resource identifier (entry ID, email ID, etc.) + namespace: string; // Resource scope: "Cms/Entry/Article", "Mailer/Email" + actionType: string; // Operation: "Publish", "Unpublish", "Send", "Delete" + targetId: string; // Resource identifier (entry ID, email ID, etc.) scheduledBy: Identity; scheduledOn: Date; - payload?: any; // Action-specific data - error?: string; // Error if execution failed + payload?: any; // Action-specific data + error?: string; // Error if execution failed } /** * Scheduler Input - When to schedule */ export interface ISchedulerInput { - scheduleOn: Date; // Future date (required) -} - -/** - * List Parameters - */ -export interface ISchedulerListParams { - where?: { - namespace?: string; // Filter by resource scope - actionType?: string; // Filter by operation type - targetId?: string; // Filter by specific resource - scheduledBy?: string; // Filter by who scheduled - scheduledOn_gte?: string; - scheduledOn_lte?: string; - }; - sort?: Array; - limit?: number; - after?: string; -} - -export interface ISchedulerListResponse { - data: IScheduledAction[]; - meta: { - hasMoreItems: boolean; - totalCount: number; - cursor: string | null; - }; + scheduleOn: Date; // Future date (required) } /** @@ -77,9 +52,8 @@ export interface IScheduledActionHandler { handle(action: IScheduledAction): Promise; } -export const ScheduledActionHandler = createAbstraction( - "ScheduledActionHandler" -); +export const ScheduledActionHandler = + createAbstraction("ScheduledActionHandler"); export namespace ScheduledActionHandler { export type Interface = IScheduledActionHandler; @@ -97,10 +71,17 @@ export interface ISchedulerService { exists(id: string): Promise; } -export const SchedulerService = createAbstraction( - "SchedulerService" -); +export const SchedulerService = createAbstraction("SchedulerService"); export namespace SchedulerService { export type Interface = ISchedulerService; } + +/** + * ScheduledActionModel - A CMS model used by the scheduler for persistence. + */ +export const ScheduledActionModel = createAbstraction("ScheduledActionModel"); + +export namespace ScheduledActionModel { + export type Interface = CmsModel; +} From 667454159832bbbd98f7ca40d9deb28c8eddc162 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 18 Nov 2025 22:09:52 +0100 Subject: [PATCH 38/71] wip: implement api-scheduler --- .../__tests__/{handler => }/Handler.test.ts | 42 +--- .../{service => }/SchedulerService.test.ts | 99 +++----- .../createScheduleRecordId.test.ts | 2 +- .../actions/PublishHandlerAction.test.ts | 193 --------------- .../actions/UnpublishHandlerAction.test.ts | 206 ---------------- .../__tests__/handler/eventHandler.test.ts | 59 ----- packages/api-scheduler/__tests__/mocks/cms.ts | 18 -- .../__tests__/mocks/context/helpers.ts | 4 - .../__tests__/mocks/context/plugins.ts | 80 +++--- .../mocks/context/tenancySecurity.ts | 8 +- .../__tests__/mocks/context/useHandler.ts | 1 - .../api-scheduler/__tests__/mocks/fetcher.ts | 10 - .../mocks/schedulerManifestPlugin.ts | 4 +- .../__tests__/mocks/schedulerModel.ts | 3 +- .../api-scheduler/__tests__/mocks/service.ts | 12 - .../__tests__/mocks/targetModel.ts | 51 ---- .../scheduler/ScheduleExecutor.test.ts | 70 ------ .../scheduler/ScheduleFetcher.test.ts | 100 -------- .../__tests__/scheduler/Scheduler.test.ts | 68 ------ .../actions/PublishScheduleAction.test.ts | 231 ------------------ .../actions/UnpublishScheduleAction.test.ts | 220 ----------------- .../scheduler/createScheduler.test.ts | 31 --- packages/api-scheduler/package.json | 2 + packages/api-scheduler/src/constants.ts | 2 + .../src/domain/createScheduleRecordId.ts | 22 ++ .../ScheduleAction/ScheduleActionUseCase.ts | 15 +- .../EventBridgeSchedulerService.ts | 25 +- .../api-scheduler/src/shared/abstractions.ts | 4 +- packages/api-scheduler/tsconfig.build.json | 39 ++- packages/api-scheduler/tsconfig.json | 39 ++- yarn.lock | 25 ++ 31 files changed, 179 insertions(+), 1506 deletions(-) rename packages/api-scheduler/__tests__/{handler => }/Handler.test.ts (80%) rename packages/api-scheduler/__tests__/{service => }/SchedulerService.test.ts (68%) rename packages/api-scheduler/__tests__/{scheduler => }/createScheduleRecordId.test.ts (96%) delete mode 100644 packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts delete mode 100644 packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts delete mode 100644 packages/api-scheduler/__tests__/handler/eventHandler.test.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/cms.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/fetcher.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/service.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/targetModel.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts delete mode 100644 packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts create mode 100644 packages/api-scheduler/src/domain/createScheduleRecordId.ts diff --git a/packages/api-scheduler/__tests__/handler/Handler.test.ts b/packages/api-scheduler/__tests__/Handler.test.ts similarity index 80% rename from packages/api-scheduler/__tests__/handler/Handler.test.ts rename to packages/api-scheduler/__tests__/Handler.test.ts index d5f7767cda6..dcd6ab95e1e 100644 --- a/packages/api-scheduler/__tests__/handler/Handler.test.ts +++ b/packages/api-scheduler/__tests__/Handler.test.ts @@ -1,29 +1,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; import type { CmsContext, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; -import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; -import { ProcessRecordsUseCase } from "~/features/ProcessRecords/ProcessRecordsUseCase.js"; -import { - ProcessRecordsUseCase as ProcessRecordsAbstraction, - RecordAction -} from "~/features/ProcessRecords/index.js"; -import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; -import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; -import { SchedulerFactory } from "~/features/Scheduler/abstractions"; - -const createEventScheduleRecordId = (targetId: string): string => { - return `${createScheduleRecordIdWithVersion(targetId)}`; -}; +import { createMockScheduleClient } from "./mocks/scheduleClient"; +import { createScheduleRecordId } from "./domain/createScheduleRecordId"; describe("Handler", () => { const targetId = "target-id#0001"; @@ -39,24 +19,6 @@ describe("Handler", () => { context = await contextHandler.handler(); }); - const createScheduleEntry = async ( - values: Omit - ): Promise> => { - const getModel = context.container.resolve(GetModelUseCase); - const createEntry = context.container.resolve(CreateEntryUseCase); - - const modelResult = await getModel.execute(SCHEDULE_MODEL_ID); - const model = modelResult.value; - - const res = await createEntry.execute(model, { - id: createScheduleRecordId(values.targetId), - ...values, - targetModelId: MOCK_TARGET_MODEL_ID - }); - - return res.value as CmsEntry; - }; - it("should fail to handle due to missing schedule entry", async () => { const testContainer = context.container.createChildContainer(); // Register a use case without actions diff --git a/packages/api-scheduler/__tests__/service/SchedulerService.test.ts b/packages/api-scheduler/__tests__/SchedulerService.test.ts similarity index 68% rename from packages/api-scheduler/__tests__/service/SchedulerService.test.ts rename to packages/api-scheduler/__tests__/SchedulerService.test.ts index 66312e35d9a..be3129aa7e8 100644 --- a/packages/api-scheduler/__tests__/service/SchedulerService.test.ts +++ b/packages/api-scheduler/__tests__/SchedulerService.test.ts @@ -1,8 +1,4 @@ -import { SchedulerService } from "~/service/SchedulerService.js"; -import type { - ISchedulerServiceCreateInput, - ISchedulerServiceUpdateInput -} from "~/service/types.js"; +import { EventBridgeSchedulerService } from "~/features/SchedulerService/EventBridgeSchedulerService.js"; import { WebinyError } from "@webiny/error"; import { mockClient } from "aws-sdk-client-mock"; import { @@ -13,6 +9,9 @@ import { UpdateScheduleCommand } from "@webiny/aws-sdk/client-scheduler/index.js"; import { describe, expect, it, vi } from "vitest"; +import type { ISchedulerService } from "~/shared/abstractions.js"; + +type SchedulerServiceCreateInput = Parameters[0]; describe("SchedulerService", () => { const lambdaArn = "arn:aws:lambda:us-east-1:123456789012:function:test"; @@ -30,32 +29,23 @@ describe("SchedulerService", () => { } }); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); - const input: ISchedulerServiceCreateInput = { + const input: SchedulerServiceCreateInput = { id: "schedule-1", scheduleOn: new Date(Date.now() + 1000000) }; - const result = await service.create(input); - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); + await service.create(input); + // We are simply testing if running the service succeeds. + // The service returns `void`, so there's no value to expect. }); it("throws if creating a schedule in the past", async () => { const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); - const input: ISchedulerServiceCreateInput = { + const input: SchedulerServiceCreateInput = { id: "schedule-1", scheduleOn: new Date(Date.now() - 100000) }; @@ -66,7 +56,7 @@ describe("SchedulerService", () => { } catch (ex) { expect(ex).toBeInstanceOf(WebinyError); expect(ex.message).toContain( - `Cannot create a schedule for "schedule-1" with date in the past:` + `Cannot create a schedule for "schedule-1" with date in the past` ); } }); @@ -84,33 +74,23 @@ describe("SchedulerService", () => { } }); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); - const input: ISchedulerServiceUpdateInput = { + const input: SchedulerServiceCreateInput = { id: "schedule-1", scheduleOn: new Date(Date.now() + 1000000) }; - const result = await service.update(input); - - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); + await service.update(input); + // We are simply testing if running the service succeeds. + // The service returns `void`, so there's no value to expect. }); it("throws if updating a schedule in the past", async () => { const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); - const input: ISchedulerServiceUpdateInput = { + const input: SchedulerServiceCreateInput = { id: "schedule-1", scheduleOn: new Date(Date.now()) }; @@ -121,41 +101,31 @@ describe("SchedulerService", () => { } catch (ex) { expect(ex).toBeInstanceOf(WebinyError); expect(ex.message).toContain( - `Cannot update an existing schedule for "schedule-1" with date in the past:` + `Cannot update an existing schedule for "schedule-1" with date in the past` ); } }); it("deletes a schedule successfully if it exists", async () => { const client = mockClient(SchedulerClient); - client.on(DeleteScheduleCommand).resolves({ $metadata: { httpStatusCode: 999 } }); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); vi.spyOn(service, "exists").mockResolvedValue(true); - const result = await service.delete("schedule-1"); + await service.delete("schedule-1"); - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); + // We are simply testing if running the service succeeds. + // The service returns `void`, so there's no value to expect. }); it("does not delete a schedule if it does not exist", async () => { const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); vi.spyOn(service, "exists").mockResolvedValue(false); try { @@ -177,10 +147,7 @@ describe("SchedulerService", () => { } }); - const service = new SchedulerService({ - getClient: () => client, - config - }); + const service = new EventBridgeSchedulerService(() => client, config); const result = await service.exists("schedule-1"); @@ -194,10 +161,8 @@ describe("SchedulerService", () => { error.name = "ResourceNotFoundException"; throw error; }); - const service = new SchedulerService({ - getClient: () => client, - config - }); + + const service = new EventBridgeSchedulerService(() => client, config); const result = await service.exists("schedule-1"); expect(result).toBe(false); @@ -208,13 +173,9 @@ describe("SchedulerService", () => { client.on(GetScheduleCommand).callsFake(async () => { throw new Error("Unknown error."); }); - const service = new SchedulerService({ - getClient: () => client, - config - }); - const result = await service.exists("schedule-1"); + const service = new EventBridgeSchedulerService(() => client, config); - expect(result).toEqual(false); + await expect(() => service.exists("schedule-1")).rejects.toThrow(); }); }); diff --git a/packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts b/packages/api-scheduler/__tests__/createScheduleRecordId.test.ts similarity index 96% rename from packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts rename to packages/api-scheduler/__tests__/createScheduleRecordId.test.ts index 857e51e0433..1a770dfff47 100644 --- a/packages/api-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts +++ b/packages/api-scheduler/__tests__/createScheduleRecordId.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createScheduleRecordId, createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; +} from "~/domain/createScheduleRecordId.js"; import { SCHEDULE_ID_PREFIX } from "~/constants.js"; describe("createScheduleRecordId", () => { diff --git a/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts b/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts deleted file mode 100644 index 274243385e5..00000000000 --- a/packages/api-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; -import { RecordAction } from "~/features/ProcessRecords/index.js"; - -describe("PublishHandlerAction", () => { - it("should only handle publish action", async () => { - // @ts-expect-error No deps provided; we only test a `canHandle`. - const action = new PublishRecordAction(); - - expect(action.canHandle({ type: ScheduleType.publish })).toBe(true); - expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(false); - }); - - it("should throw an error if target entry does not exist", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - try { - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - expect(result).toEqual("Should not reach here."); - } catch (ex) { - expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); - } - }); - - it("should publish an entry which is not published yet", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - }); - - it("should republish an entry which is already published", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [rePublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(rePublishedEntry.id).toBe("target-id#0001"); - const publishedDate = new Date(publishedEntry.lastPublishedOn!); - const rePublishedDate = new Date(rePublishedEntry.lastPublishedOn!); - expect(rePublishedDate > publishedDate).toBeTrue(); - }); - - it("should publish a new entry revision if the existing published revision is different", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - await context.cms.getPublishedEntriesByIds(model, ["target-id#0001"]); - - const newEntryRevision = await context.cms.createEntryRevisionFrom( - model, - "target-id#0001", - { - title: "Test Entry - Updated" - } - ); - expect(newEntryRevision).toMatchObject({ - id: "target-id#0002", - values: { - title: "Test Entry - Updated" - } - }); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0002", - model - }); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, ["target-id"]); - expect(publishedEntry.id).toBe("target-id#0002"); - }); -}); diff --git a/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts b/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts deleted file mode 100644 index c25df5c6dc3..00000000000 --- a/packages/api-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { describe, expect, it, vi } from "vitest"; -import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; -import { RecordAction } from "~/features/ProcessRecords/index.js"; - -describe("UnpublishHandlerAction", () => { - it("should only handle unpublish action", async () => { - // @ts-expect-error No deps provided; we only test a `canHandle`. - const action = new UnpublishRecordAction(); - - expect(action.canHandle({ type: ScheduleType.publish })).toBe(false); - expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(true); - }); - - it("should throw an error if target entry does not exist", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - try { - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - expect(result).toEqual("Should not reach here."); - } catch (ex) { - expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); - } - }); - - it("should do nothing if entry is not published", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - console.warn = vi.fn(); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - expect(console.warn).toHaveBeenCalledWith( - `Entry "target-id#0001" is not published, nothing to unpublish.` - ); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry).toBeUndefined(); - }); - - it("should unpublish an entry if it matches", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - await context.cms.publishEntry(model, "target-id#0001"); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(unpublishedEntry).toBeUndefined(); - }); - - it("should unpublish entry even if it does not match the target ID (revision).", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - await context.cms.publishEntry(model, "target-id#0001"); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - const newEntryRevision = await context.cms.createEntryRevisionFrom( - model, - publishedEntry.id, - { - title: "Test Entry - Updated" - } - ); - - expect(newEntryRevision.id).toEqual("target-id#0002"); - - await context.cms.publishEntry(model, "target-id#0002"); - - const [publishedOverwriteEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0002" - ]); - - expect(publishedOverwriteEntry.id).toBe("target-id#0002"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(unpublishedEntry).toBeUndefined(); - }); -}); diff --git a/packages/api-scheduler/__tests__/handler/eventHandler.test.ts b/packages/api-scheduler/__tests__/handler/eventHandler.test.ts deleted file mode 100644 index e9a65365540..00000000000 --- a/packages/api-scheduler/__tests__/handler/eventHandler.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { RawEventHandler } from "@webiny/handler-aws/raw/index.js"; -import { createScheduledCmsActionEventHandler } from "~/handler/index.js"; -import { registry } from "@webiny/handler-aws/registry.js"; -import type { LambdaContext } from "@webiny/handler-aws/types.js"; -import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { createScheduleRecordId } from "~/scheduler/createScheduleRecordId.js"; -import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; - -describe("Scheduler Event Handler", () => { - const lambdaContext = {} as LambdaContext; - it("should trigger handle an event which matches scheduled event", async () => { - const eventHandler = createScheduledCmsActionEventHandler(); - - expect(eventHandler).toBeInstanceOf(RawEventHandler); - - const event: IWebinyScheduledCmsActionEvent = { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createScheduleRecordId("target-id#0001"), - scheduleOn: new Date().toISOString() - } - }; - const sourceHandler = registry.getHandler(event, lambdaContext); - - expect(sourceHandler).toMatchObject({ - name: "handler-aws-event-bridge-scheduled-cms-action-event" - }); - expect(sourceHandler.canUse(event, lambdaContext)).toBe(true); - - /** - * Should break because there are no contexts loaded. - */ - const result = await sourceHandler.handle({ - params: { - plugins: [eventHandler] - }, - event, - context: lambdaContext - }); - /** - * We are expecting an error because the context is not set up properly - we dont need it to be set up. - */ - expect(result).toEqual({ - body: '{"message":"No registration found for SchedulerFactory"}', - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "POST", - "access-control-allow-origin": "*", - "cache-control": "no-store", - connection: "keep-alive", - "content-length": "56", - "content-type": "text/plain; charset=utf-8", - date: expect.toBeDateString() - }, - isBase64Encoded: false, - statusCode: 500 - }); - }); -}); diff --git a/packages/api-scheduler/__tests__/mocks/cms.ts b/packages/api-scheduler/__tests__/mocks/cms.ts deleted file mode 100644 index 0fd27d8e0d7..00000000000 --- a/packages/api-scheduler/__tests__/mocks/cms.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { vi } from "vitest"; -import type { ScheduleExecutorCms } from "~/scheduler/ScheduleExecutor.js"; -import type { ScheduleFetcherCms } from "~/scheduler/ScheduleFetcher.js"; - -export const createMockCms = ( - cms?: Partial -): ScheduleExecutorCms & ScheduleFetcherCms => { - return { - listLatestEntries: vi.fn(), - publishEntry: vi.fn(), - unpublishEntry: vi.fn(), - updateEntry: vi.fn(), - createEntry: vi.fn(), - deleteEntry: vi.fn(), - getEntryById: vi.fn(), - ...cms - }; -}; diff --git a/packages/api-scheduler/__tests__/mocks/context/helpers.ts b/packages/api-scheduler/__tests__/mocks/context/helpers.ts index 0fdda22b2a0..7b582355ba4 100644 --- a/packages/api-scheduler/__tests__/mocks/context/helpers.ts +++ b/packages/api-scheduler/__tests__/mocks/context/helpers.ts @@ -47,10 +47,6 @@ export const createPermissions = (permissions?: PermissionsArg[]): PermissionsAr }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US", "de-DE"] } ]; }; diff --git a/packages/api-scheduler/__tests__/mocks/context/plugins.ts b/packages/api-scheduler/__tests__/mocks/context/plugins.ts index 634939a78d4..18474516a7f 100644 --- a/packages/api-scheduler/__tests__/mocks/context/plugins.ts +++ b/packages/api-scheduler/__tests__/mocks/context/plugins.ts @@ -4,24 +4,20 @@ import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api- import { createTenancyAndSecurity } from "./tenancySecurity"; import type { PermissionsArg } from "./helpers"; import { createPermissions } from "./helpers"; -import type { ContextPlugin } from "@webiny/api"; -import type { ScheduleContext } from "~/types.js"; import type { Plugin, PluginCollection } from "@webiny/plugins/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; -import { createHeadlessCmsScheduler } from "~/index.js"; import type { SchedulerClient, SchedulerClientConfig } from "@webiny/aws-sdk/client-scheduler/index.js"; import { createSchedulerManifestPlugin } from "~tests/mocks/schedulerManifestPlugin.js"; -import { createMockTargetModelPlugins } from "~tests/mocks/targetModel.js"; import apiKeyAuthentication from "@webiny/api-core/legacy/security/plugins/apiKeyAuthentication.js"; import apiKeyAuthorization from "@webiny/api-core/legacy/security/plugins/apiKeyAuthorization.js"; -import type { ApiKey } from "@webiny/api-core/types/security.js"; import { createApiCore } from "@webiny/api-core"; import type { IdentityData } from "@webiny/api-core/features/security/IdentityContext/index.js"; import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js"; +import { createSchedulerContext } from "~/context.js"; export interface CreateHandlerCoreParams { getScheduleClient: (config?: SchedulerClientConfig) => Pick; @@ -42,15 +38,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { name: "Root", parent: null }; - const locale = "en-US"; - const { - permissions, - identity, - plugins = [], - topPlugins = [], - bottomPlugins = [], - setupTenancyAndSecurityGraphQL - } = params; + const { permissions, identity, plugins = [], topPlugins = [], bottomPlugins = [] } = params; const apiCoreStorage = getStorageOps("apiCore"); const cmsStorage = getStorageOps("cms"); @@ -58,9 +46,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { return { storageOperations: cmsStorage.storageOperations, tenant, - locale, plugins: [ - createMockTargetModelPlugins(), topPlugins, ...cmsStorage.plugins, createApiCore({ @@ -68,40 +54,40 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { testProjectLicense: createTestWcpLicense() }), ...createTenancyAndSecurity({ - setupGraphQL: setupTenancyAndSecurityGraphQL, permissions: createPermissions(permissions), identity }), createSchedulerManifestPlugin(), - { - type: "context", - name: "context-security-tenant", - async apply(context) { - context.security.getApiKeyByToken = async ( - token: string - ): Promise => { - if (!token || token !== "aToken") { - return null; - } - const apiKey = "a1234567890"; - return { - id: apiKey, - name: apiKey, - tenant: tenant.id, - permissions: identity?.permissions || [], - token, - createdBy: { - id: "test", - displayName: "test", - type: "admin" - }, - description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION - }; - }; - } - } as ContextPlugin, + // { + // type: "context", + // name: "context-security-tenant", + // async apply(context) { + // context.container.register() + // context.security.getApiKeyByToken = async ( + // token: string + // ): Promise => { + // if (!token || token !== "aToken") { + // return null; + // } + // const apiKey = "a1234567890"; + // return { + // id: apiKey, + // name: apiKey, + // tenant: tenant.id, + // permissions: identity?.permissions || [], + // token, + // createdBy: { + // id: "test", + // displayName: "test", + // type: "admin" + // }, + // description: "test", + // createdOn: new Date().toISOString(), + // webinyVersion: context.WEBINY_VERSION + // }; + // }; + // } + // } as ContextPlugin, apiKeyAuthentication({ identityType: "api-key" }), apiKeyAuthorization({ identityType: "api-key" }), createHeadlessCmsContext({ @@ -110,7 +96,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { createHeadlessCmsGraphQL(), plugins, graphQLHandlerPlugins(), - createHeadlessCmsScheduler({ + createSchedulerContext({ getClient: config => { return params.getScheduleClient(config); } diff --git a/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts index 46d43e0e387..5ce2b90a789 100644 --- a/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts +++ b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts @@ -1,10 +1,10 @@ import type { Plugin } from "@webiny/plugins/Plugin"; import { ContextPlugin } from "@webiny/api"; import { BeforeHandlerPlugin } from "@webiny/handler"; -import type { ScheduleContext } from "~/types.js"; import { SecurityPermission } from "@webiny/api-core/types/security"; import { IdentityData } from "@webiny/api-core/features/IdentityContext"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; interface Config { permissions: SecurityPermission[]; @@ -19,7 +19,7 @@ export const defaultIdentity: IdentityData = { export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plugin[] => { return [ - new ContextPlugin(async context => { + new ContextPlugin(async context => { await context.tenancy.createTenant({ id: "root", name: "Root", @@ -52,7 +52,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu tags: [] }); }), - new ContextPlugin(async context => { + new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", name: "Root", @@ -72,7 +72,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu return permissions || [{ name: "*" }]; }); }), - new BeforeHandlerPlugin(context => { + new BeforeHandlerPlugin(context => { const { headers = {} } = context.request || {}; if (headers["authorization"]) { return context.security.authenticate(headers["authorization"]); diff --git a/packages/api-scheduler/__tests__/mocks/context/useHandler.ts b/packages/api-scheduler/__tests__/mocks/context/useHandler.ts index 79d19c82789..3f277e5b662 100644 --- a/packages/api-scheduler/__tests__/mocks/context/useHandler.ts +++ b/packages/api-scheduler/__tests__/mocks/context/useHandler.ts @@ -34,7 +34,6 @@ export const useHandler = (params: CreateHandlerCoreParams plugins, identity: params.identity || defaultIdentity, tenant: core.tenant, - locale: core.locale, elasticsearch: elasticsearchClient, handler: (input?: CmsHandlerEvent) => { const payload: CmsHandlerEvent = { diff --git a/packages/api-scheduler/__tests__/mocks/fetcher.ts b/packages/api-scheduler/__tests__/mocks/fetcher.ts deleted file mode 100644 index 3a6a69af5e5..00000000000 --- a/packages/api-scheduler/__tests__/mocks/fetcher.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { vi } from "vitest"; -import type { IScheduleFetcher } from "~/scheduler/types.js"; - -export const createMockFetcher = (input?: Partial): IScheduleFetcher => { - return { - getScheduled: vi.fn(), - listScheduled: vi.fn(), - ...input - }; -}; diff --git a/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts b/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts index c4504fa3c25..972154d90bd 100644 --- a/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts +++ b/packages/api-scheduler/__tests__/mocks/schedulerManifestPlugin.ts @@ -1,10 +1,10 @@ import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { PutCommand } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import type { ScheduleContext } from "~/types.js"; import { ContextPlugin } from "@webiny/api"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export const createSchedulerManifestPlugin = () => { - return new ContextPlugin(async context => { + return new ContextPlugin(async context => { const manifest = { lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-scheduler-function", roleArn: "arn:aws:iam::123456789012:role/my-scheduler-role" diff --git a/packages/api-scheduler/__tests__/mocks/schedulerModel.ts b/packages/api-scheduler/__tests__/mocks/schedulerModel.ts index 01e12ad0091..3b19b6e7b1f 100644 --- a/packages/api-scheduler/__tests__/mocks/schedulerModel.ts +++ b/packages/api-scheduler/__tests__/mocks/schedulerModel.ts @@ -1,5 +1,5 @@ import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { createSchedulerModel } from "~/scheduler/model.js"; +import { createSchedulerModel } from "~/domain/model.js"; export const createMockSchedulerModel = (input?: Partial): CmsModel => { const model = createSchedulerModel(); @@ -7,7 +7,6 @@ export const createMockSchedulerModel = (input?: Partial): CmsModel => ...model.contentModel, webinyVersion: "0.0.0", tenant: "root", - locale: "en-US", ...input }; }; diff --git a/packages/api-scheduler/__tests__/mocks/service.ts b/packages/api-scheduler/__tests__/mocks/service.ts deleted file mode 100644 index a7ff87a4207..00000000000 --- a/packages/api-scheduler/__tests__/mocks/service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vi } from "vitest"; -import type { ISchedulerService } from "~/service/types.js"; - -export const createMockService = (input?: Partial): ISchedulerService => { - return { - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - exists: vi.fn(), - ...input - }; -}; diff --git a/packages/api-scheduler/__tests__/mocks/targetModel.ts b/packages/api-scheduler/__tests__/mocks/targetModel.ts deleted file mode 100644 index ea6d7c8b119..00000000000 --- a/packages/api-scheduler/__tests__/mocks/targetModel.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createModelGroupPlugin, createModelPlugin } from "@webiny/api-headless-cms"; -import type { CmsModel } from "@webiny/api-headless-cms/types"; - -const group = { - id: "default", - name: "Default Group" -}; - -export const MOCK_TARGET_MODEL_ID = "targetModel"; - -export const createMockTargetModel = (): CmsModel => { - return { - modelId: MOCK_TARGET_MODEL_ID, - name: "Target Model", - description: "This is a mock target model for testing purposes.", - fields: [ - { - id: "title", - fieldId: "title", - storageId: "text@title", - type: "text", - label: "Title" - } - ], - group, - singularApiName: "targetModel", - pluralApiName: "targetModels", - layout: [["title"]], - createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), - webinyVersion: "0.0.0", - tenant: "root", - locale: "en-US", - titleFieldId: "title" - }; -}; - -export const createMockTargetModelPlugins = () => { - return [ - createModelGroupPlugin({ - ...group, - slug: "default", - description: null, - icon: "fa/fas" - }), - createModelPlugin({ - ...createMockTargetModel(), - isPrivate: undefined - }) - ]; -}; diff --git a/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts b/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts deleted file mode 100644 index d66942e1734..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ScheduleExecutor } from "~/scheduler/ScheduleExecutor.js"; -import { createMockFetcher } from "~tests/mocks/fetcher.js"; -import { type IScheduleRecord, ScheduleType } from "~/scheduler/types.js"; -import { createScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; - -describe("ScheduleExecutor", () => { - it("should execute not find action for publishing", async () => { - const fetcher = createMockFetcher({ - async getScheduled(targetId: string): Promise { - return createScheduleRecord({ - id: `schedule-record-id-${targetId}`, - targetId, - scheduledOn: new Date(), - type: ScheduleType.publish, - title: `Scheduled for ${targetId}`, - scheduledBy: createMockGetIdentity()(), - model: {} as any - }); - } - }); - const executor = new ScheduleExecutor({ - actions: [], - fetcher - }); - - await expect( - executor.schedule("target-id#0001", { - type: ScheduleType.publish, - scheduleOn: new Date() - }) - ).rejects.toThrow(`No action found for input type "${ScheduleType.publish}"`); - - await expect(executor.cancel("target-id#0001")).rejects.toThrow( - `No action found for input type "${ScheduleType.publish}"` - ); - }); - - it("should execute not find action for unpublishing", async () => { - const fetcher = createMockFetcher({ - async getScheduled(targetId: string): Promise { - return createScheduleRecord({ - id: `schedule-record-id-${targetId}`, - targetId, - scheduledOn: new Date(), - type: ScheduleType.unpublish, - title: `Scheduled for ${targetId}`, - scheduledBy: createMockGetIdentity()(), - model: {} as any - }); - } - }); - const executor = new ScheduleExecutor({ - actions: [], - fetcher - }); - - await expect( - executor.schedule("target-id#0001", { - type: ScheduleType.unpublish, - scheduleOn: new Date() - }) - ).rejects.toThrow(`No action found for input type "${ScheduleType.unpublish}"`); - - await expect(executor.cancel("target-id#0001")).rejects.toThrow( - `No action found for input type "${ScheduleType.unpublish}"` - ); - }); -}); diff --git a/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts b/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts deleted file mode 100644 index e9dc12a02ca..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ScheduleFetcher } from "~/scheduler/ScheduleFetcher.js"; -import type { - CmsEntryMeta, - CmsIdentity, - CmsModel, - HeadlessCms -} from "@webiny/api-headless-cms/types/index.js"; -import type { ISchedulerListParams } from "~/scheduler/types.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { describe, expect, it, vi } from "vitest"; - -describe("ScheduleFetcher", () => { - const targetModel: CmsModel = { modelId: "targetModel", titleFieldId: "title" } as any; - const schedulerModel: CmsModel = { modelId: "schedulerModel", titleFieldId: "title" } as any; - const entryMeta: CmsEntryMeta = { hasMoreItems: false, totalCount: 1, cursor: null }; - - function createMockCms(overrides: Partial = {}) { - return { - getEntryById: vi.fn(), - listLatestEntries: vi.fn(), - ...overrides - } as unknown as HeadlessCms; - } - - it("getScheduled returns a record if found", async () => { - const identity: CmsIdentity = { - id: "user-1", - displayName: "User 1", - type: "admin" - }; - const mockedEntry = { - id: "schedule-1", - values: { - type: "publish", - title: "Test Entry", - targetModelId: targetModel.modelId, - targetId: "target-1", - scheduledOn: dateToISOString(new Date()), - scheduledBy: identity - }, - savedOn: new Date().toISOString(), - savedBy: identity - }; - const cms = createMockCms({ - getEntryById: vi.fn().mockResolvedValue(mockedEntry) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const result = await fetcher.getScheduled("target-1"); - expect(result).not.toBeNull(); - expect(result?.targetId).toBe("target-1"); - expect(result?.title).toBe("Test Entry"); - }); - - it("getScheduled returns null if not found", async () => { - const cms = createMockCms({ - getEntryById: vi.fn().mockRejectedValue(new NotFoundError("not found")) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const result = await fetcher.getScheduled("target-1"); - expect(result).toBeNull(); - }); - - it("getScheduled throws on unknown error", async () => { - const cms = createMockCms({ - getEntryById: vi.fn().mockRejectedValue(new Error("unknown")) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - await expect(fetcher.getScheduled("target-1")).rejects.toThrow("unknown"); - }); - - it("listScheduled returns data and meta", async () => { - const entry = { - id: "schedule-1", - values: { - type: "publish", - title: "Test Entry", - targetId: "target-1", - dateOn: new Date().toISOString(), - scheduledBy: { id: "user-1" } - }, - savedOn: new Date().toISOString(), - savedBy: { id: "user-1" } - }; - const cms = createMockCms({ - listLatestEntries: vi.fn().mockResolvedValue([[entry], entryMeta]) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const params: ISchedulerListParams = { - where: {}, - sort: undefined, - limit: undefined, - after: undefined - }; - const result = await fetcher.listScheduled(params); - expect(result.data.length).toBe(1); - expect(result.data[0].title).toBe("Test Entry"); - expect(result.meta).toEqual(entryMeta); - }); -}); diff --git a/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts b/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts deleted file mode 100644 index 1a499fb8dbf..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/Scheduler.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Scheduler } from "~/scheduler/Scheduler.js"; -import { type ISchedulerInput, ScheduleType } from "~/scheduler/types.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Scheduler", () => { - const fetcher = { - getScheduled: vi.fn(), - listScheduled: vi.fn() - }; - const executor = { - schedule: vi.fn(), - cancel: vi.fn() - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create an instance of Scheduler", () => { - const result = new Scheduler({ fetcher, executor }); - - expect(result).toBeInstanceOf(Scheduler); - }); - - it("should call fetcher.getScheduled when fetching a schedule", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - const expectedResult = { id: "schedule-id#0001", targetId, dateOn: new Date() }; - fetcher.getScheduled.mockResolvedValue(expectedResult); - const result = await scheduler.getScheduled(targetId); - expect(fetcher.getScheduled).toHaveBeenCalledWith(targetId); - expect(result).toEqual(expectedResult); - }); - - it("should call fetcher.listScheduled when listing schedules", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const expectedResult = [ - { id: "schedule-id#0001", targetId: "target-id#0001", dateOn: new Date() } - ]; - fetcher.listScheduled.mockResolvedValue(expectedResult); - const result = await scheduler.listScheduled({ - sort: undefined, - after: undefined, - limit: undefined, - where: {} - }); - expect(fetcher.listScheduled).toHaveBeenCalled(); - expect(result).toEqual(expectedResult); - }); - - it("should call executor.schedule when scheduling a task", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - const input: ISchedulerInput = { - type: ScheduleType.publish, - scheduleOn: new Date() - }; - await scheduler.schedule(targetId, input); - expect(executor.schedule).toHaveBeenCalledWith(targetId, input); - }); - - it("should call executor.cancel when canceling a scheduled task", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - await scheduler.cancel(targetId); - expect(executor.cancel).toHaveBeenCalledWith(targetId); - }); -}); diff --git a/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts b/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts deleted file mode 100644 index 11310ae0247..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createMockService } from "~tests/mocks/service.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { PublishScheduleAction } from "~/scheduler/actions/PublishScheduleAction.js"; -import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; -import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { SchedulerService } from "~/service/SchedulerService.js"; -import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; -import { mockClient } from "aws-sdk-client-mock"; -import { createMockFetcher } from "~tests/mocks/fetcher"; - -describe("PublishScheduleAction", () => { - const service = createMockService(); - const fetcher = createMockFetcher(); - const getIdentity = createMockGetIdentity(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - - it("should schedule a publish action immediately", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - immediately: true, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: expect.any(Date), - unpublishOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - }); - - it("should publish an entry immediately if the scheduleOn is in the past", async () => { - const updateEntryMock = vi.fn(async () => { - return {} as CmsEntry; - }); - const cms = createMockCms({ - updateEntry: updateEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const scheduleOn = new Date(Date.now() - 1000000); - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: expect.any(Date), - unpublishOn: undefined, - dateOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - - expect(updateEntryMock).toHaveBeenCalledTimes(1); - expect(updateEntryMock).toHaveBeenCalledWith(targetModel, "target-id#0002", { - firstPublishedBy: getIdentity(), - firstPublishedOn: scheduleOn.toISOString(), - lastPublishedBy: getIdentity(), - lastPublishedOn: scheduleOn.toISOString() - }); - }); - - it("should schedule a publish action for a future date", async () => { - const client = mockClient(SchedulerClient); - client.on(CreateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - const service = new SchedulerService({ - getClient: () => client, - config: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-function", - roleArn: "arn:aws:iam::123456789012:role/my-role" - } - }); - const scheduleOn = new Date(Date.now() + 1000000); - - const crateEntryMock = vi.fn(async () => { - const entry: Pick, "id" | "values" | "savedBy"> = { - id: createScheduleRecordIdWithVersion(`target-id#0002`), - values: { - targetId: "target-id#0002", - type: ScheduleType.publish, - scheduledOn: dateToISOString(scheduleOn), - title: "Test Entry", - targetModelId: targetModel.modelId, - scheduledBy: getIdentity() - }, - savedBy: getIdentity() - }; - return entry as CmsEntry; - }); - const cms = createMockCms({ - // @ts-expect-error - createEntry: crateEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: scheduleOn, - unpublishOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - - expect(crateEntryMock).toHaveBeenCalledTimes(1); - expect(crateEntryMock).toHaveBeenCalledWith(schedulerModel, { - id: createScheduleRecordId(`target-id#0002`), - scheduledBy: getIdentity(), - scheduledOn: dateToISOString(scheduleOn), - targetId: "target-id#0002", - targetModelId: "targetModel", - title: "Test Entry", - type: ScheduleType.publish - }); - }); -}); diff --git a/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts b/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts deleted file mode 100644 index e569654859c..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { createMockService } from "~tests/mocks/service.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { UnpublishScheduleAction } from "~/scheduler/actions/UnpublishScheduleAction.js"; -import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; -import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { mockClient } from "aws-sdk-client-mock"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; -import { SchedulerService } from "~/service/SchedulerService.js"; -import { createMockFetcher } from "~tests/mocks/fetcher.js"; -import { describe, expect, it, vi } from "vitest"; - -describe("UnpublishScheduleAction", () => { - const service = createMockService(); - const fetcher = createMockFetcher(); - const getIdentity = createMockGetIdentity(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - - it("should unpublish an entry immediately if input.immediately is true", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - immediately: true, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: expect.any(Date), - type: ScheduleType.unpublish, - title: "Test Entry" - }); - }); - - it("should unpublish an entry immediately if the scheduleOn is in the past", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const scheduleOn = new Date(Date.now() - 1000000); - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: scheduleOn, - type: ScheduleType.unpublish, - title: "Test Entry" - }); - }); - - it("should schedule an unpublish action for a future date", async () => { - const client = mockClient(SchedulerClient); - client.on(CreateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config: { - roleArn: "arn:aws:iam::123456789012:role/scheduler-role", - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:scheduler-lambda" - } - }); - - const scheduleOn = new Date(Date.now() + 1000000); - - const createEntryMock = vi.fn(async () => { - const entry: Pick, "id" | "values" | "savedBy"> = { - id: createScheduleRecordIdWithVersion(`target-id#0002`), - values: { - targetId: "target-id#0002", - type: ScheduleType.unpublish, - scheduledOn: dateToISOString(scheduleOn), - title: "Test Entry", - targetModelId: targetModel.modelId, - scheduledBy: getIdentity() - }, - savedBy: getIdentity() - }; - return entry; - }); - const cms = createMockCms({ - // @ts-expect-error - createEntry: createEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - scheduleOn: scheduleOn, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: scheduleOn, - type: ScheduleType.unpublish, - title: "Test Entry" - }); - - expect(createEntryMock).toHaveBeenCalledTimes(1); - expect(createEntryMock).toHaveBeenCalledWith(schedulerModel, { - id: createScheduleRecordId(`target-id#0002`), - scheduledOn: scheduleOn.toISOString(), - scheduledBy: getIdentity(), - targetId: "target-id#0002", - targetModelId: "targetModel", - title: "Test Entry", - type: ScheduleType.unpublish - }); - }); -}); diff --git a/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts b/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts deleted file mode 100644 index e3a6965e821..00000000000 --- a/packages/api-scheduler/__tests__/scheduler/createScheduler.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createScheduler } from "~/scheduler/createScheduler.js"; -import { createMockService } from "~tests/mocks/service.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockSecurity } from "~tests/mocks/security.js"; -import { Scheduler } from "~/scheduler/Scheduler.js"; - -describe("createScheduler", () => { - const service = createMockService(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - const cms = createMockCms(); - const security = createMockSecurity(); - - it("should create a scheduler", async () => { - const result = await createScheduler({ - service, - cms, - security, - schedulerModel - }); - expect(result).toBeInstanceOf(Function); - - const scheduler = result(targetModel); - expect(scheduler).toHaveProperty("fetcher"); - expect(scheduler).toHaveProperty("executor"); - expect(scheduler).toBeInstanceOf(Scheduler); - }); -}); diff --git a/packages/api-scheduler/package.json b/packages/api-scheduler/package.json index 62fddbc1672..35b118d75b9 100644 --- a/packages/api-scheduler/package.json +++ b/packages/api-scheduler/package.json @@ -16,6 +16,7 @@ "license": "MIT", "dependencies": { "@webiny/api": "0.0.0", + "@webiny/api-core": "0.0.0", "@webiny/api-headless-cms": "0.0.0", "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", @@ -29,6 +30,7 @@ "@webiny/db-dynamodb": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/project-utils": "0.0.0", + "@webiny/wcp": "0.0.0", "jest-dynalite": "^3.6.1", "rimraf": "^6.0.1", "typescript": "5.9.3", diff --git a/packages/api-scheduler/src/constants.ts b/packages/api-scheduler/src/constants.ts index ae54b0d3a31..b6e6b70ec3c 100644 --- a/packages/api-scheduler/src/constants.ts +++ b/packages/api-scheduler/src/constants.ts @@ -5,3 +5,5 @@ export const SCHEDULE_ID_PREFIX = "wby-schedule-"; * Everything else will result in immediately running the action. */ export const SCHEDULE_MIN_FUTURE_SECONDS = 65; + +export const SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER = "WebinyScheduledCmsAction"; diff --git a/packages/api-scheduler/src/domain/createScheduleRecordId.ts b/packages/api-scheduler/src/domain/createScheduleRecordId.ts new file mode 100644 index 00000000000..16a3e792315 --- /dev/null +++ b/packages/api-scheduler/src/domain/createScheduleRecordId.ts @@ -0,0 +1,22 @@ +import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; +import { SCHEDULE_ID_PREFIX } from "~/constants.js"; +import { zeroPad } from "@webiny/utils/zeroPad.js"; + +export const createScheduleRecordIdWithVersion = (input: string): string => { + const recordId = createScheduleRecordId(input); + const { id } = parseIdentifier(recordId); + + return `${id}#0001`; +}; + +export const createScheduleRecordId = (input: string): string => { + /** + * A possibility that the input is already a schedule record ID? + */ + if (input.includes(SCHEDULE_ID_PREFIX)) { + return input; + } + + const { id, version } = parseIdentifier(input); + return `${SCHEDULE_ID_PREFIX}${id}-${zeroPad(version || 1)}`; +}; diff --git a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts index 922a90fd8de..ff7f7d1918e 100644 --- a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts +++ b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts @@ -8,6 +8,7 @@ import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstrac import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; import type { IScheduledAction, ISchedulerInput, Identity } from "~/shared/abstractions.js"; import { ScheduledActionPersistenceError, SchedulerServiceError } from "~/domain/errors.js"; +import { createScheduleRecordIdWithVersion } from "~/domain/createScheduleRecordId.js"; /** * Schedules an action for future execution @@ -42,7 +43,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { const identity = this.identityContext.getIdentity(); // Generate unique schedule ID - const scheduleId = this.generateScheduleId(namespace, actionType, targetId); + const scheduleId = createScheduleRecordIdWithVersion(targetId); const existingResult = await this.getScheduledAction.execute(scheduleId); @@ -188,18 +189,6 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { payload }); } - - /** - * Generates a unique schedule ID from namespace + actionType + targetId - * - * Format: "namespace-actionType-targetId" with special chars replaced - * Example: "Cms_Entry_Article-Publish-article-1#0001" - */ - private generateScheduleId(namespace: string, actionType: string, targetId: string): string { - // TODO: is this really necessary? - const namespaceNormalized = namespace.replace(/\//g, "_"); - return `${namespaceNormalized}-${actionType}-${targetId}`; - } } export const ScheduleActionUseCase = UseCaseAbstraction.createImplementation({ diff --git a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts index 320d64a581b..be98aa15393 100644 --- a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts +++ b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts @@ -7,6 +7,7 @@ import { GetScheduleCommand, type SchedulerClient } from "@webiny/aws-sdk/client-scheduler"; +import { NotFoundError } from "@webiny/handler-graphql"; export interface ISchedulerConfig { lambdaArn: string; @@ -27,15 +28,18 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { private config: ISchedulerConfig ) {} - async create(params: { id: string; scheduleOn: Date; payload: any }): Promise { + async create(params: { id: string; scheduleOn: Date; payload?: any }): Promise { const { id, scheduleOn, payload } = params; // Validate date is in future if (scheduleOn <= new Date()) { throw new WebinyError( - "Cannot schedule in the past", + `Cannot create a schedule for "${id}" with date in the past`, "INVALID_SCHEDULE_DATE", - { scheduleOn, id } + { + scheduleOn, + id + } ); } @@ -65,13 +69,13 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { ); } - async update(params: { id: string; scheduleOn: Date; payload: any }): Promise { + async update(params: { id: string; scheduleOn: Date; payload?: any }): Promise { const { id, scheduleOn, payload } = params; // Validate date is in future if (scheduleOn <= new Date()) { throw new WebinyError( - "Cannot schedule in the past", + `Cannot update an existing schedule for "${id}" with date in the past`, "INVALID_SCHEDULE_DATE", { scheduleOn, id } ); @@ -99,14 +103,15 @@ export class EventBridgeSchedulerService implements SchedulerService.Interface { async delete(id: string): Promise { const client = this.getClient(); + const exists = await this.exists(id); + if (!exists) { + throw new WebinyError(`Cannot delete schedule "${id}" because it does not exist.`); + } + try { await client.send(new DeleteScheduleCommand({ Name: id })); } catch (ex) { - // Ignore if schedule doesn't exist - if (ex.name === "ResourceNotFoundException") { - return; - } - throw ex; + throw WebinyError.from(ex); } } diff --git a/packages/api-scheduler/src/shared/abstractions.ts b/packages/api-scheduler/src/shared/abstractions.ts index c282f34c649..7d6926c5750 100644 --- a/packages/api-scheduler/src/shared/abstractions.ts +++ b/packages/api-scheduler/src/shared/abstractions.ts @@ -65,8 +65,8 @@ export namespace ScheduledActionHandler { * Abstracts the underlying scheduling infrastructure (AWS EventBridge, Azure Logic Apps, etc.) */ export interface ISchedulerService { - create(params: { id: string; scheduleOn: Date; payload: any }): Promise; - update(params: { id: string; scheduleOn: Date; payload: any }): Promise; + create(params: { id: string; scheduleOn: Date; payload?: any }): Promise; + update(params: { id: string; scheduleOn: Date; payload?: any }): Promise; delete(id: string): Promise; exists(id: string): Promise; } diff --git a/packages/api-scheduler/tsconfig.build.json b/packages/api-scheduler/tsconfig.build.json index c79ef6f6691..316612c4386 100644 --- a/packages/api-scheduler/tsconfig.build.json +++ b/packages/api-scheduler/tsconfig.build.json @@ -3,17 +3,16 @@ "include": ["src"], "references": [ { "path": "../api/tsconfig.build.json" }, + { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../pubsub/tsconfig.build.json" }, - { "path": "../api-core/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, - { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, - { "path": "../handler-db/tsconfig.build.json" } + { "path": "../wcp/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", @@ -24,20 +23,6 @@ "~tests/*": ["./__tests__/*"], "@webiny/api/*": ["../api/src/*"], "@webiny/api": ["../api/src"], - "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], - "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], - "@webiny/feature/api": ["../feature/src/api/index.js"], - "@webiny/feature/admin": ["../feature/src/admin/index.js"], - "@webiny/feature/*": ["../feature/src/*"], - "@webiny/feature": ["../feature/src"], - "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], - "@webiny/handler-graphql": ["../handler-graphql/src"], - "@webiny/plugins/*": ["../plugins/src/*"], - "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], "@webiny/api-core/features/EventPublisher": [ "../api-core/src/features/eventPublisher/index.js" ], @@ -164,14 +149,26 @@ ], "@webiny/api-core/*": ["../api-core/src/*"], "@webiny/api-core": ["../api-core/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], - "@webiny/handler/*": ["../handler/src/*"], - "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], - "@webiny/handler-db/*": ["../handler-db/src/*"], - "@webiny/handler-db": ["../handler-db/src"] + "@webiny/wcp/*": ["../wcp/src/*"], + "@webiny/wcp": ["../wcp/src"] }, "baseUrl": "." } diff --git a/packages/api-scheduler/tsconfig.json b/packages/api-scheduler/tsconfig.json index f8740723dd5..1da8bb089a0 100644 --- a/packages/api-scheduler/tsconfig.json +++ b/packages/api-scheduler/tsconfig.json @@ -3,17 +3,16 @@ "include": ["src", "__tests__"], "references": [ { "path": "../api" }, + { "path": "../api-core" }, { "path": "../api-headless-cms" }, { "path": "../error" }, { "path": "../feature" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, { "path": "../pubsub" }, - { "path": "../api-core" }, { "path": "../db-dynamodb" }, - { "path": "../handler" }, { "path": "../handler-aws" }, - { "path": "../handler-db" } + { "path": "../wcp" } ], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], @@ -24,20 +23,6 @@ "~tests/*": ["./__tests__/*"], "@webiny/api/*": ["../api/src/*"], "@webiny/api": ["../api/src"], - "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], - "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], - "@webiny/feature/api": ["../feature/src/api/index.js"], - "@webiny/feature/admin": ["../feature/src/admin/index.js"], - "@webiny/feature/*": ["../feature/src/*"], - "@webiny/feature": ["../feature/src"], - "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], - "@webiny/handler-graphql": ["../handler-graphql/src"], - "@webiny/plugins/*": ["../plugins/src/*"], - "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], "@webiny/api-core/features/EventPublisher": [ "../api-core/src/features/eventPublisher/index.js" ], @@ -164,14 +149,26 @@ ], "@webiny/api-core/*": ["../api-core/src/*"], "@webiny/api-core": ["../api-core/src"], + "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], + "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/error/*": ["../error/src/*"], + "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], + "@webiny/handler-graphql/*": ["../handler-graphql/src/*"], + "@webiny/handler-graphql": ["../handler-graphql/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/pubsub/*": ["../pubsub/src/*"], + "@webiny/pubsub": ["../pubsub/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], - "@webiny/handler/*": ["../handler/src/*"], - "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], - "@webiny/handler-db/*": ["../handler-db/src/*"], - "@webiny/handler-db": ["../handler-db/src"] + "@webiny/wcp/*": ["../wcp/src/*"], + "@webiny/wcp": ["../wcp/src"] }, "baseUrl": "." } diff --git a/yarn.lock b/yarn.lock index 275732894c1..77865480020 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14995,6 +14995,31 @@ __metadata: languageName: unknown linkType: soft +"@webiny/api-scheduler@workspace:packages/api-scheduler": + version: 0.0.0-use.local + resolution: "@webiny/api-scheduler@workspace:packages/api-scheduler" + dependencies: + "@webiny/api": "npm:0.0.0" + "@webiny/api-core": "npm:0.0.0" + "@webiny/api-headless-cms": "npm:0.0.0" + "@webiny/build-tools": "npm:0.0.0" + "@webiny/db-dynamodb": "npm:0.0.0" + "@webiny/error": "npm:0.0.0" + "@webiny/feature": "npm:0.0.0" + "@webiny/handler-aws": "npm:0.0.0" + "@webiny/handler-graphql": "npm:0.0.0" + "@webiny/plugins": "npm:0.0.0" + "@webiny/project-utils": "npm:0.0.0" + "@webiny/pubsub": "npm:0.0.0" + "@webiny/wcp": "npm:0.0.0" + jest-dynalite: "npm:^3.6.1" + rimraf: "npm:^6.0.1" + typescript: "npm:5.9.3" + vitest: "npm:^3.2.4" + zod: "npm:^3.25.76" + languageName: unknown + linkType: soft + "@webiny/api-security-auth0@workspace:packages/api-security-auth0": version: 0.0.0-use.local resolution: "@webiny/api-security-auth0@workspace:packages/api-security-auth0" From d7cb2c5c7eca31dc4984591ec41f1fdbc1e2f9b3 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 13:17:26 +0100 Subject: [PATCH 39/71] wip: implement api-scheduler --- .../api-scheduler/__tests__/Handler.test.ts | 220 --------------- .../__tests__/ScheduledActionId.test.ts | 15 + .../api-scheduler/__tests__/Scheduler.test.ts | 266 ++++++++++++++++++ .../__tests__/createScheduleRecordId.test.ts | 40 --- .../src/domain/ScheduledActionId.ts | 8 + .../domain/ScheduledActionIdWithVersion.ts | 9 + .../src/domain/createScheduleRecordId.ts | 22 -- packages/api-scheduler/src/domain/model.ts | 7 + .../CancelScheduledActionUseCase.ts | 17 +- .../ExecuteScheduledActionUseCase.ts | 27 +- .../ExecuteScheduledAction/feature.ts | 2 + .../GetScheduledActionUseCase.ts | 7 +- .../ListScheduledActionsUseCase.ts | 2 +- .../ScheduleAction/ScheduleActionUseCase.ts | 20 +- .../src/features/SchedulerFeature.ts | 4 - .../EventBridgeSchedulerService.ts | 1 - 16 files changed, 345 insertions(+), 322 deletions(-) delete mode 100644 packages/api-scheduler/__tests__/Handler.test.ts create mode 100644 packages/api-scheduler/__tests__/ScheduledActionId.test.ts create mode 100644 packages/api-scheduler/__tests__/Scheduler.test.ts delete mode 100644 packages/api-scheduler/__tests__/createScheduleRecordId.test.ts create mode 100644 packages/api-scheduler/src/domain/ScheduledActionId.ts create mode 100644 packages/api-scheduler/src/domain/ScheduledActionIdWithVersion.ts delete mode 100644 packages/api-scheduler/src/domain/createScheduleRecordId.ts diff --git a/packages/api-scheduler/__tests__/Handler.test.ts b/packages/api-scheduler/__tests__/Handler.test.ts deleted file mode 100644 index dcd6ab95e1e..00000000000 --- a/packages/api-scheduler/__tests__/Handler.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import type { CmsContext, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; -import { createMockScheduleClient } from "./mocks/scheduleClient"; -import { createScheduleRecordId } from "./domain/createScheduleRecordId"; - -describe("Handler", () => { - const targetId = "target-id#0001"; - - let context: CmsContext; - - beforeEach(async () => { - const contextHandler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - context = await contextHandler.handler(); - }); - - it("should fail to handle due to missing schedule entry", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual( - `Entry "${createEventScheduleRecordId(targetId)}" was not found!` - ); - expect(ex.code).toEqual("Cms/Entry/NotFound"); - } - }); - - it("should fail to find action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.register(UnpublishRecordAction); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual( - `No action found for schedule record ID: wby-schedule-target-id-0001#0001` - ); - } - }); - - it("should handle action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.register(PublishRecordAction); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const targetEntry = await context.cms.createEntry(targetModel, { - id: "target-id", - title: "Test Entry" - }); - - expect(targetEntry.id).toEqual(targetId); - - const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - const schedulerFactory = testContainer.resolve(SchedulerFactory); - const scheduler = schedulerFactory.useModel(targetModel); - - const getScheduleEntry = await scheduler.getScheduled(createScheduleRecordId(targetId)); - - expect(getScheduleEntry).toMatchObject({ - id: expect.any(String), - targetId, - model: targetModel, - title: "Test Entry", - publishOn: expect.any(Date), - unpublishOn: undefined, - type: ScheduleType.publish - }); - - await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - const [afterDeleteScheduledEntry] = await context.cms.getEntriesByIds(scheduleModel, [ - scheduleEntry.id - ]); - - expect(afterDeleteScheduledEntry).toBeUndefined(); - - const [afterActionTargetEntry] = await context.cms.getPublishedEntriesByIds(targetModel, [ - targetId - ]); - expect(afterActionTargetEntry).toMatchObject({ - id: targetId, - values: { - title: "Test Entry" - }, - status: "published" - }); - }); - - it("should throw an error while handling action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.registerInstance(RecordAction, { - canHandle: () => true, - async handle(): Promise { - throw new Error("Unknown error."); - } - }); - - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const targetEntry = await context.cms.createEntry(targetModel, { - id: "target-id", - title: "Test Entry" - }); - expect(targetEntry.id).toEqual(targetId); - - const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual("Unknown error."); - } - - const afterActionErrorScheduleEntry = await context.cms.getEntryById( - scheduleModel, - scheduleEntry.id - ); - expect(afterActionErrorScheduleEntry).toMatchObject({ - id: scheduleEntry.id, - values: { - title: "Test Entry", - error: "Unknown error." - } - }); - }); -}); diff --git a/packages/api-scheduler/__tests__/ScheduledActionId.test.ts b/packages/api-scheduler/__tests__/ScheduledActionId.test.ts new file mode 100644 index 00000000000..d3392a6cbf5 --- /dev/null +++ b/packages/api-scheduler/__tests__/ScheduledActionId.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { SCHEDULE_ID_PREFIX } from "~/constants.js"; +import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; + +describe("ScheduledActionId", () => { + it("should create a valid schedule action id", () => { + const result = ScheduledActionId.from({ + namespace: "Cms/Entry/Article", + actionType: "Publish", + targetId: "target-id#0001" + }); + + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}5aebe6a0ee483f0a203e729d`); + }); +}); diff --git a/packages/api-scheduler/__tests__/Scheduler.test.ts b/packages/api-scheduler/__tests__/Scheduler.test.ts new file mode 100644 index 00000000000..8969eb66eff --- /dev/null +++ b/packages/api-scheduler/__tests__/Scheduler.test.ts @@ -0,0 +1,266 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useHandler } from "~tests/mocks/context/useHandler.js"; +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; +import { createMockScheduleClient } from "./mocks/scheduleClient.js"; +import { ExecuteScheduledActionUseCase } from "~/features/ExecuteScheduledAction/abstractions.js"; +import { ScheduleActionUseCase } from "~/features/ScheduleAction/abstractions.js"; +import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; +import { ScheduledActionHandler } from "~/shared/abstractions.js"; +import type { IScheduledAction } from "~/shared/abstractions.js"; +import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; +import { ListScheduledActionsUseCase } from "~/features/ListScheduledActions/index.js"; +import { CancelScheduledActionUseCase } from "~/features/CancelScheduledAction/index.js"; + +describe("Scheduler", () => { + const targetId = "target-id#0001"; + const namespace = "TestNamespace"; + const actionType = "TestAction"; + + let context: CmsContext; + + beforeEach(async () => { + const contextHandler = useHandler({ + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + context = await contextHandler.handler(); + }); + + it("should fail to handle due to missing schedule entry", async () => { + const testContainer = context.container.createChildContainer(); + + const executeScheduledAction = testContainer.resolve(ExecuteScheduledActionUseCase); + + const result = await executeScheduledAction.execute("non-existent-id"); + + expect(result.isFail()).toBe(true); + expect(result.error.code).toBe("Scheduler/ScheduledAction/NotFound"); + }); + + it("should fail when no handler is registered", async () => { + const testContainer = context.container.createChildContainer(); + + const scheduleAction = testContainer.resolve(ScheduleActionUseCase); + const executeScheduledAction = testContainer.resolve(ExecuteScheduledActionUseCase); + + // Schedule an action + const scheduleResult = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: new Date(Date.now() + 1000000) }, + { some: "payload" } + ); + + expect(scheduleResult.isFail()).toBe(false); + + const actionId = ScheduledActionId.from({ namespace, actionType, targetId }); + + // Try to execute - should fail because no handler registered + const result = await executeScheduledAction.execute(actionId); + + expect(result.isFail()).toBe(true); + expect(result.error.code).toBe("Scheduler/Handler/NotFound"); + expect(result.error.message).toContain(namespace); + expect(result.error.message).toContain(actionType); + }); + + it("should invoke handler when action is executed", async () => { + const testContainer = context.container; + + // Create a mock handler that tracks if it was called + const mockHandler = { + canHandle: vi.fn((ns: string, type: string) => ns === namespace && type === actionType), + handle: vi.fn(async (action: IScheduledAction) => { + // Handler was invoked successfully + }) + }; + + testContainer.registerInstance(ScheduledActionHandler, mockHandler); + + const scheduleAction = testContainer.resolve(ScheduleActionUseCase); + const executeScheduledAction = testContainer.resolve(ExecuteScheduledActionUseCase); + const getScheduledAction = testContainer.resolve(GetScheduledActionUseCase); + + // Schedule an action + const scheduleResult = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: new Date(Date.now() + 1000000) }, + { some: "payload" } + ); + + expect(scheduleResult.isFail()).toBe(false); + + const scheduleId = ScheduledActionId.from({ namespace, actionType, targetId }); + + // Verify schedule entry exists before execution + const getBeforeResult = await getScheduledAction.execute(scheduleId); + expect(getBeforeResult.isFail()).toBe(false); + expect(getBeforeResult.value.id).toBe(scheduleId); + expect(getBeforeResult.value.namespace).toBe(namespace); + expect(getBeforeResult.value.actionType).toBe(actionType); + + // Execute the scheduled action + const executeResult = await executeScheduledAction.execute(scheduleId); + + expect(executeResult.isFail()).toBe(false); + + // Verify handler was called with correct action + expect(mockHandler.canHandle).toHaveBeenCalled(); + expect(mockHandler.handle).toHaveBeenCalledTimes(1); + expect(mockHandler.handle).toHaveBeenCalledWith( + expect.objectContaining({ + id: scheduleId, + namespace, + actionType, + targetId, + payload: { some: "payload" } + }) + ); + + // Verify schedule entry was deleted after successful execution + const getAfterResult = await getScheduledAction.execute(scheduleId); + expect(getAfterResult.isFail()).toBe(true); + expect(getAfterResult.error.code).toBe("Scheduler/ScheduledAction/NotFound"); + }); + + it("should store error when handler throws", async () => { + const testContainer = context.container; + + // Register a handler that always throws + testContainer.registerInstance(ScheduledActionHandler, { + canHandle: () => true, + async handle(): Promise { + throw new Error("Handler execution failed"); + } + }); + + const scheduleAction = testContainer.resolve(ScheduleActionUseCase); + const executeScheduledAction = testContainer.resolve(ExecuteScheduledActionUseCase); + const getScheduledAction = testContainer.resolve(GetScheduledActionUseCase); + + // Schedule an action + const scheduleResult = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: new Date(Date.now() + 1000000) }, + { some: "payload" } + ); + + expect(scheduleResult.isFail()).toBe(false); + + const scheduleId = ScheduledActionId.from({ namespace, actionType, targetId }); + + // Execute the scheduled action - should fail + const result = await executeScheduledAction.execute(scheduleId); + + expect(result.isFail()).toBe(true); + expect(result.error.code).toBe("Scheduler/Execution/Failed"); + expect(result.error.message).toContain("Handler execution failed"); + + // Verify schedule entry still exists with error stored + const getErrorResult = await getScheduledAction.execute(scheduleId); + expect(getErrorResult.isFail()).toBe(false); + expect(getErrorResult.value.error).toContain("Handler execution failed"); + }); + + it("should update existing schedule when rescheduling", async () => { + const testContainer = context.container; + + const mockHandler = { + canHandle: vi.fn(() => true), + handle: vi.fn(async () => {}) + }; + + testContainer.registerInstance(ScheduledActionHandler, mockHandler); + + const scheduleAction = testContainer.resolve(ScheduleActionUseCase); + const getScheduledAction = testContainer.resolve(GetScheduledActionUseCase); + + const scheduleId = ScheduledActionId.from({ namespace, actionType, targetId }); + const firstDate = new Date(Date.now() + 1000000); + const secondDate = new Date(Date.now() + 2000000); + + // Schedule first time + const firstResult = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: firstDate }, + { version: 1 } + ); + + expect(firstResult.isFail()).toBe(false); + + // Verify first schedule + const getFirstResult = await getScheduledAction.execute(scheduleId); + expect(getFirstResult.isFail()).toBe(false); + expect(getFirstResult.value.scheduledOn.getTime()).toBe(firstDate.getTime()); + expect(getFirstResult.value.payload).toEqual({ version: 1 }); + + // Reschedule (same namespace + actionType + targetId) + const secondResult = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: secondDate }, + { version: 2 } + ); + + expect(secondResult.isFail()).toBe(false); + + // Verify schedule was updated, not duplicated + const getSecondResult = await getScheduledAction.execute(scheduleId); + expect(getSecondResult.isFail()).toBe(false); + expect(getSecondResult.value.id).toBe(scheduleId); // Same ID + expect(getSecondResult.value.scheduledOn.getTime()).toBe(secondDate.getTime()); + expect(getSecondResult.value.payload).toEqual({ version: 2 }); + }); + + it("should list and cancel all scheduled actions", async () => { + const testContainer = context.container; + + const scheduleAction = testContainer.resolve(ScheduleActionUseCase); + const cancelAction = testContainer.resolve(CancelScheduledActionUseCase); + const listScheduledActions = testContainer.resolve(ListScheduledActionsUseCase); + + // Schedule an action + const scheduleResult1 = await scheduleAction.execute( + namespace, + actionType, + targetId, + { scheduleOn: new Date(Date.now() + 1000000) }, + { some: "payload" } + ); + + const scheduleResult2 = await scheduleAction.execute( + namespace, + "ColonizeMars", + targetId, + { scheduleOn: new Date(Date.now() + 1000000) }, + { some: "payload" } + ); + + expect(scheduleResult1.isOk()).toBe(true); + expect(scheduleResult2.isOk()).toBe(true); + + const scheduledActionsResult = await listScheduledActions.execute({ where: { namespace } }); + expect(scheduledActionsResult.isOk()).toBe(true); + + const scheduledActions = scheduledActionsResult.value.items; + + expect(scheduledActions.length).toBe(2); + + for (const action of scheduledActions) { + await cancelAction.execute(action.id); + } + + // Assert all actions were cancelled + const allActions = await listScheduledActions.execute({ where: { namespace } }); + expect(allActions.value.items.length).toBe(0); + }); +}); diff --git a/packages/api-scheduler/__tests__/createScheduleRecordId.test.ts b/packages/api-scheduler/__tests__/createScheduleRecordId.test.ts deleted file mode 100644 index 1a770dfff47..00000000000 --- a/packages/api-scheduler/__tests__/createScheduleRecordId.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/domain/createScheduleRecordId.js"; -import { SCHEDULE_ID_PREFIX } from "~/constants.js"; - -describe("createScheduleRecordId", () => { - it("should create a valid schedule record ID with version", () => { - const result = createScheduleRecordIdWithVersion("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - }); - - it("should create a valid schedule record ID from already created record ID with version", () => { - const result = createScheduleRecordIdWithVersion("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - - const rerunResult = createScheduleRecordIdWithVersion(result); - - expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - }); - - it("should create a valid schedule record ID without version", () => { - const result = createScheduleRecordId("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - }); - - it("should create a valid schedule record ID from already created record ID without version", () => { - const result = createScheduleRecordId("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - - const rerunResult = createScheduleRecordId(result); - - expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - }); -}); diff --git a/packages/api-scheduler/src/domain/ScheduledActionId.ts b/packages/api-scheduler/src/domain/ScheduledActionId.ts new file mode 100644 index 00000000000..32dc885f1f6 --- /dev/null +++ b/packages/api-scheduler/src/domain/ScheduledActionId.ts @@ -0,0 +1,8 @@ +import { createCacheKey } from "@webiny/utils"; +import { SCHEDULE_ID_PREFIX } from "~/constants.js"; + +export class ScheduledActionId { + static from(params: { namespace: string; actionType: string; targetId: string }) { + return [SCHEDULE_ID_PREFIX, createCacheKey(params).slice(-24)].join(""); + } +} diff --git a/packages/api-scheduler/src/domain/ScheduledActionIdWithVersion.ts b/packages/api-scheduler/src/domain/ScheduledActionIdWithVersion.ts new file mode 100644 index 00000000000..d6abb07dc64 --- /dev/null +++ b/packages/api-scheduler/src/domain/ScheduledActionIdWithVersion.ts @@ -0,0 +1,9 @@ +export class ScheduledActionIdWithVersion { + static from(id: string) { + if (id.endsWith("#0001")) { + return id; + } + + return `${id}#0001`; + } +} diff --git a/packages/api-scheduler/src/domain/createScheduleRecordId.ts b/packages/api-scheduler/src/domain/createScheduleRecordId.ts deleted file mode 100644 index 16a3e792315..00000000000 --- a/packages/api-scheduler/src/domain/createScheduleRecordId.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; -import { SCHEDULE_ID_PREFIX } from "~/constants.js"; -import { zeroPad } from "@webiny/utils/zeroPad.js"; - -export const createScheduleRecordIdWithVersion = (input: string): string => { - const recordId = createScheduleRecordId(input); - const { id } = parseIdentifier(recordId); - - return `${id}#0001`; -}; - -export const createScheduleRecordId = (input: string): string => { - /** - * A possibility that the input is already a schedule record ID? - */ - if (input.includes(SCHEDULE_ID_PREFIX)) { - return input; - } - - const { id, version } = parseIdentifier(input); - return `${SCHEDULE_ID_PREFIX}${id}-${zeroPad(version || 1)}`; -}; diff --git a/packages/api-scheduler/src/domain/model.ts b/packages/api-scheduler/src/domain/model.ts index b652e122959..d8627321637 100644 --- a/packages/api-scheduler/src/domain/model.ts +++ b/packages/api-scheduler/src/domain/model.ts @@ -87,6 +87,13 @@ export const createSchedulerModel = () => { storageId: "text@error", type: "text", label: "Error" + }, + { + id: "payload", + fieldId: "payload", + storageId: "json@payload", + type: "json", + label: "Payload" } ] }); diff --git a/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts b/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts index d9ab425a4bb..63c88758ef1 100644 --- a/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts +++ b/packages/api-scheduler/src/features/CancelScheduledAction/CancelScheduledActionUseCase.ts @@ -2,12 +2,9 @@ import { Result } from "@webiny/feature/api"; import { CancelScheduledActionUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; -import { - ScheduledActionNotFoundError, - ScheduledActionPersistenceError, - SchedulerServiceError -} from "~/domain/errors.js"; +import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; +import { ScheduledActionIdWithVersion } from "~/domain/ScheduledActionIdWithVersion.js"; /** * Cancels a scheduled action @@ -26,20 +23,22 @@ class CancelScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { private model: ScheduledActionModel.Interface ) {} - async execute(scheduleId: string): Promise> { - // Check if schedule exists - const getResult = await this.getScheduledActionUseCase.execute(scheduleId); + async execute(id: string): Promise> { + // Check if scheduled action exists + const getResult = await this.getScheduledActionUseCase.execute(id); if (getResult.isFail()) { const error = getResult.error; if (error.code === "Scheduler/ScheduledAction/NotFound") { - return Result.fail(new ScheduledActionNotFoundError(scheduleId)); + return Result.fail(new ScheduledActionNotFoundError(id)); } return Result.fail(error); } + const scheduleId = ScheduledActionIdWithVersion.from(id); + // Delete EventBridge schedule // Note: We continue even if this fails, as the schedule might already be executed/deleted try { diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts index 62ac80fc6c2..a27358e77ab 100644 --- a/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/ExecuteScheduledActionUseCase.ts @@ -6,12 +6,10 @@ import { } from "./abstractions.js"; import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; import { ScheduledActionHandler, ScheduledActionModel } from "~/shared/abstractions.js"; -import { - ScheduledActionNotFoundError, - ScheduledActionPersistenceError -} from "~/domain/errors.js"; +import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; +import { ScheduledActionIdWithVersion } from "~/domain/ScheduledActionIdWithVersion.js"; /** * Executes a scheduled action @@ -32,23 +30,24 @@ class ExecuteScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface private model: ScheduledActionModel.Interface ) {} - async execute(scheduleId: string): Promise> { + async execute(id: string): Promise> { // Load scheduled action - const getResult = await this.getScheduledActionUseCase.execute(scheduleId); + const getResult = await this.getScheduledActionUseCase.execute(id); if (getResult.isFail()) { const error = getResult.error; if (error.code === "Scheduler/ScheduledAction/NotFound") { - return Result.fail(new ScheduledActionNotFoundError(scheduleId)); + return Result.fail(new ScheduledActionNotFoundError(id)); } return Result.fail(error); } const scheduledAction = getResult.value; + const scheduleId = ScheduledActionIdWithVersion.from(id); - // Check if handler can handle this action + // Check if the handler can handle this action if (!this.actionHandler.canHandle(scheduledAction.namespace, scheduledAction.actionType)) { const error = new HandlerNotFoundError( scheduledAction.namespace, @@ -68,14 +67,10 @@ class ExecuteScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface await this.actionHandler.handle(scheduledAction); // Delete schedule entry on success - const deleteResult = await this.deleteEntryUseCase.execute( - this.model, - scheduleId, - { - force: true, - permanently: true - } - ); + const deleteResult = await this.deleteEntryUseCase.execute(this.model, scheduleId, { + force: true, + permanently: true + }); if (deleteResult.isFail()) { return Result.fail( diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts index 1c932168616..2e511ac6479 100644 --- a/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/feature.ts @@ -1,5 +1,6 @@ import { createFeature } from "@webiny/feature/api"; import { ExecuteScheduledActionUseCase } from "./ExecuteScheduledActionUseCase.js"; +import { ScheduledActionHandlerComposite } from "~/features/ExecuteScheduledAction/ScheduledActionHandlerComposite.js"; /** * ExecuteScheduledAction Feature @@ -11,5 +12,6 @@ export const ExecuteScheduledActionFeature = createFeature({ name: "ExecuteScheduledAction", register(container) { container.register(ExecuteScheduledActionUseCase); + container.registerComposite(ScheduledActionHandlerComposite); } }); diff --git a/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts index 94845dad062..2d5a9feafbc 100644 --- a/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts +++ b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts @@ -4,6 +4,7 @@ import type { IScheduledAction } from "~/shared/abstractions.js"; import { ScheduledActionModel } from "~/shared/abstractions.js"; import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; +import { ScheduledActionIdWithVersion } from "~/domain/ScheduledActionIdWithVersion.js"; /** * Retrieves a scheduled action by its ID @@ -19,8 +20,9 @@ class GetScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { private model: ScheduledActionModel.Interface ) {} - async execute(scheduleId: string): Promise> { + async execute(id: string): Promise> { // Get entry from CMS + const scheduleId = ScheduledActionIdWithVersion.from(id); const entryResult = await this.getEntryByIdUseCase.execute(this.model, scheduleId); if (entryResult.isFail()) { @@ -34,13 +36,14 @@ class GetScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { const entry = entryResult.value; return Result.ok({ - id: entry.id, + id: entry.entryId, namespace: entry.values.namespace, actionType: entry.values.actionType, targetId: entry.values.targetId, scheduledBy: entry.values.scheduledBy, scheduledOn: new Date(entry.values.scheduledOn), payload: entry.values.payload, + title: entry.values.title, error: entry.values.error }); } diff --git a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts index fb43fcdee0c..6027a29777c 100644 --- a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts +++ b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts @@ -46,7 +46,7 @@ class ListScheduledActionsUseCaseImpl implements UseCaseAbstraction.Interface { // Transform entries to IScheduledAction format const scheduledActions: IScheduledAction[] = items.map(entry => ({ - id: entry.id, + id: entry.entryId, namespace: entry.values.namespace, actionType: entry.values.actionType, targetId: entry.values.targetId, diff --git a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts index ff7f7d1918e..f7f90ef020f 100644 --- a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts +++ b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts @@ -3,12 +3,14 @@ import { IdentityContext } from "@webiny/api-core/features/security/IdentityCont import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; +import { parseIdentifier } from "@webiny/utils"; import { ScheduleActionUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; import type { IScheduledAction, ISchedulerInput, Identity } from "~/shared/abstractions.js"; import { ScheduledActionPersistenceError, SchedulerServiceError } from "~/domain/errors.js"; -import { createScheduleRecordIdWithVersion } from "~/domain/createScheduleRecordId.js"; +import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; +import { ScheduledActionIdWithVersion } from "~/domain/ScheduledActionIdWithVersion.js"; /** * Schedules an action for future execution @@ -43,7 +45,8 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { const identity = this.identityContext.getIdentity(); // Generate unique schedule ID - const scheduleId = createScheduleRecordIdWithVersion(targetId); + const actionId = ScheduledActionId.from({ namespace, actionType, targetId }); + const scheduleId = ScheduledActionIdWithVersion.from(actionId); const existingResult = await this.getScheduledAction.execute(scheduleId); @@ -78,7 +81,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { * Creates a new schedule */ private async createSchedule( - scheduleId: string, + id: string, namespace: string, actionType: string, targetId: string, @@ -86,6 +89,8 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { identity: Identity, payload?: any ): Promise> { + const { id: scheduleId } = parseIdentifier(id); + const scheduledAction: IScheduledAction = { id: scheduleId, namespace, @@ -152,7 +157,8 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { payload?: any ): Promise> { // Update CMS entry - const updateResult = await this.updateEntryUseCase.execute(this.model, existing.id, { + const existingId = ScheduledActionIdWithVersion.from(existing.id); + const updateResult = await this.updateEntryUseCase.execute(this.model, existingId, { scheduledBy: identity, scheduledOn: input.scheduleOn.toISOString(), payload @@ -167,11 +173,11 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { // Update EventBridge schedule try { await this.schedulerService.update({ - id: existing.id, + id: existingId, scheduleOn: input.scheduleOn, payload: { ScheduledAction: { - id: existing.id, + id: existingId, namespace: existing.namespace, actionType: existing.actionType, targetId: existing.targetId @@ -196,8 +202,8 @@ export const ScheduleActionUseCase = UseCaseAbstraction.createImplementation({ dependencies: [ IdentityContext, ScheduledActionModel, - GetScheduledActionUseCase, SchedulerService, + GetScheduledActionUseCase, CreateEntryUseCase, UpdateEntryUseCase, DeleteEntryUseCase diff --git a/packages/api-scheduler/src/features/SchedulerFeature.ts b/packages/api-scheduler/src/features/SchedulerFeature.ts index 71c6a370800..2bd2dbdd073 100644 --- a/packages/api-scheduler/src/features/SchedulerFeature.ts +++ b/packages/api-scheduler/src/features/SchedulerFeature.ts @@ -4,7 +4,6 @@ import { GetScheduledActionFeature } from "./GetScheduledAction/feature.js"; import { ListScheduledActionsFeature } from "./ListScheduledActions/feature.js"; import { CancelScheduledActionFeature } from "./CancelScheduledAction/feature.js"; import { ExecuteScheduledActionFeature } from "./ExecuteScheduledAction/feature.js"; -import { ScheduledActionHandlerComposite } from "./ExecuteScheduledAction/ScheduledActionHandlerComposite.js"; /** * Main Scheduler Feature @@ -16,9 +15,6 @@ import { ScheduledActionHandlerComposite } from "./ExecuteScheduledAction/Schedu export const SchedulerFeature = createFeature({ name: "Scheduler", register(container) { - // Register the composite handler - container.registerComposite(ScheduledActionHandlerComposite); - // Register all features ScheduleActionFeature.register(container); GetScheduledActionFeature.register(container); diff --git a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts index be98aa15393..9e30cf72605 100644 --- a/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts +++ b/packages/api-scheduler/src/features/SchedulerService/EventBridgeSchedulerService.ts @@ -7,7 +7,6 @@ import { GetScheduleCommand, type SchedulerClient } from "@webiny/aws-sdk/client-scheduler"; -import { NotFoundError } from "@webiny/handler-graphql"; export interface ISchedulerConfig { lambdaArn: string; From a0772888e8c9be9be77df502b1e1b6f618b56ae9 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 21:34:38 +0100 Subject: [PATCH 40/71] wip: implement api-scheduler-headless-cms --- .../__tests__/actionHandlers.test.ts | 89 ++++++ .../__tests__/graphql/schema.test.ts | 195 ------------- .../__tests__/handler/Handler.test.ts | 258 ------------------ .../actions/PublishHandlerAction.test.ts | 193 ------------- .../actions/UnpublishHandlerAction.test.ts | 206 -------------- .../__tests__/handler/eventHandler.test.ts | 59 ---- .../__tests__/mocks/cms.ts | 18 -- .../__tests__/mocks/context/helpers.ts | 4 - .../__tests__/mocks/context/plugins.ts | 50 +--- .../mocks/context/tenancySecurity.ts | 8 +- .../__tests__/mocks/context/useHandler.ts | 1 - .../__tests__/mocks/fetcher.ts | 10 - .../mocks/schedulerManifestPlugin.ts | 4 +- .../__tests__/mocks/schedulerModel.ts | 13 - .../__tests__/mocks/security.ts | 16 -- .../__tests__/mocks/service.ts | 12 - .../__tests__/mocks/targetModel.ts | 1 - .../scheduler/ScheduleExecutor.test.ts | 70 ----- .../scheduler/ScheduleFetcher.test.ts | 100 ------- .../__tests__/scheduler/Scheduler.test.ts | 68 ----- .../actions/PublishScheduleAction.test.ts | 231 ---------------- .../actions/UnpublishScheduleAction.test.ts | 220 --------------- .../scheduler/createScheduleRecordId.test.ts | 40 --- .../scheduler/createScheduler.test.ts | 31 --- .../service/SchedulerService.test.ts | 220 --------------- .../api-headless-cms-scheduler/package.json | 4 +- .../src/constants.ts | 11 - .../api-headless-cms-scheduler/src/context.ts | 90 +----- .../CancelScheduledEntryActionUseCase.ts | 43 +++ .../abstractions.ts | 42 +++ .../CancelScheduledEntryAction/feature.ts | 14 + .../CancelScheduledEntryAction/index.ts | 1 + .../ProcessRecords/ProcessRecordsUseCase.ts | 158 ----------- .../features/ProcessRecords/abstractions.ts | 55 ---- .../actions/PublishRecordAction.ts | 98 ------- .../actions/UnpublishRecordAction.ts | 80 ------ .../src/features/ProcessRecords/feature.ts | 22 -- .../src/features/ProcessRecords/index.ts | 1 - .../ScheduleEntryActionUseCase.ts | 95 +++++++ .../ScheduleEntryAction/abstractions.ts | 54 ++++ .../PublishEntryActionHandler.ts | 138 ++++++++++ .../UnpublishEntryActionHandler.ts | 126 +++++++++ .../features/ScheduleEntryAction/feature.ts | 19 ++ .../src/features/ScheduleEntryAction/index.ts | 1 + .../src/features/Scheduler/abstractions.ts | 13 - .../src/features/Scheduler/index.ts | 1 - .../src/graphql/ActionMapper.ts | 18 ++ .../src/{scheduler => graphql}/dates.ts | 3 +- .../src/graphql/index.ts | 120 +++++--- .../src/graphql/schema.ts | 19 +- .../api-headless-cms-scheduler/src/index.ts | 23 +- .../src/manifest.ts | 61 ----- .../src/scheduler/ScheduleExecutor.ts | 78 ------ .../src/scheduler/ScheduleFetcher.ts | 76 ------ .../src/scheduler/ScheduleRecord.ts | 88 ------ .../src/scheduler/Scheduler.ts | 40 --- .../actions/PublishScheduleAction.ts | 255 ----------------- .../actions/UnpublishScheduleAction.ts | 254 ----------------- .../src/scheduler/createScheduleRecordId.ts | 22 -- .../src/scheduler/createScheduler.ts | 79 ------ .../src/scheduler/model.ts | 93 ------- .../src/scheduler/types.ts | 110 -------- .../src/service/SchedulerService.ts | 192 ------------- .../src/service/types.ts | 29 -- .../tsconfig.build.json | 21 +- .../api-headless-cms-scheduler/tsconfig.json | 21 +- .../__tests__/ScheduledActionId.test.ts | 2 +- .../api-scheduler/__tests__/Scheduler.test.ts | 63 +++-- .../__tests__/eventHandler.test.ts | 36 +++ .../__tests__/mocks/context/plugins.ts | 30 -- .../__tests__/mocks/schedulerModel.ts | 12 - .../api-scheduler/__tests__/mocks/security.ts | 16 -- packages/api-scheduler/package.json | 13 +- .../src/createEventHandler.ts} | 32 ++- packages/api-scheduler/src/createScheduler.ts | 17 ++ .../src/domain/ScheduledActionId.ts | 5 +- packages/api-scheduler/src/domain/errors.ts | 6 +- .../src/domain/isValidDate.ts} | 2 +- .../ListScheduledActionsUseCase.ts | 1 + .../ListScheduledActions/abstractions.ts | 1 + .../features/RunAction/RunActionUseCase.ts | 41 +++ .../src/features/RunAction/abstractions.ts | 45 +++ .../src/features/RunAction/feature.ts | 15 + .../src/features/RunAction/index.ts | 1 + .../ScheduleAction/ScheduleActionUseCase.ts | 69 ++--- .../features/ScheduleAction/abstractions.ts | 23 +- .../src/features/SchedulerFeature.ts | 2 + packages/api-scheduler/src/index.ts | 15 +- .../api-scheduler/src/shared/abstractions.ts | 5 +- packages/api-scheduler/tsconfig.build.json | 3 + packages/api-scheduler/tsconfig.json | 3 + .../src/Gateways/SchedulerListGateway.ts | 1 - 92 files changed, 1089 insertions(+), 4155 deletions(-) create mode 100644 packages/api-headless-cms-scheduler/__tests__/actionHandlers.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/graphql/schema.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/mocks/cms.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/mocks/fetcher.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/mocks/schedulerModel.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/mocks/security.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/mocks/service.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/Scheduler.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduler.test.ts delete mode 100644 packages/api-headless-cms-scheduler/__tests__/service/SchedulerService.test.ts delete mode 100644 packages/api-headless-cms-scheduler/src/constants.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/CancelScheduledEntryActionUseCase.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/abstractions.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/feature.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/index.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/ScheduleEntryActionUseCase.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/PublishEntryActionHandler.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/UnpublishEntryActionHandler.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/feature.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/index.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts delete mode 100644 packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts create mode 100644 packages/api-headless-cms-scheduler/src/graphql/ActionMapper.ts rename packages/api-headless-cms-scheduler/src/{scheduler => graphql}/dates.ts (83%) delete mode 100644 packages/api-headless-cms-scheduler/src/manifest.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/ScheduleExecutor.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/ScheduleFetcher.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/ScheduleRecord.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/Scheduler.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/actions/PublishScheduleAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/actions/UnpublishScheduleAction.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/createScheduleRecordId.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/model.ts delete mode 100644 packages/api-headless-cms-scheduler/src/scheduler/types.ts delete mode 100644 packages/api-headless-cms-scheduler/src/service/SchedulerService.ts delete mode 100644 packages/api-headless-cms-scheduler/src/service/types.ts create mode 100644 packages/api-scheduler/__tests__/eventHandler.test.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/schedulerModel.ts delete mode 100644 packages/api-scheduler/__tests__/mocks/security.ts rename packages/{api-headless-cms-scheduler/src/handler/index.ts => api-scheduler/src/createEventHandler.ts} (56%) create mode 100644 packages/api-scheduler/src/createScheduler.ts rename packages/{api-headless-cms-scheduler/src/utils/dateInTheFuture.ts => api-scheduler/src/domain/isValidDate.ts} (87%) create mode 100644 packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts create mode 100644 packages/api-scheduler/src/features/RunAction/abstractions.ts create mode 100644 packages/api-scheduler/src/features/RunAction/feature.ts create mode 100644 packages/api-scheduler/src/features/RunAction/index.ts diff --git a/packages/api-headless-cms-scheduler/__tests__/actionHandlers.test.ts b/packages/api-headless-cms-scheduler/__tests__/actionHandlers.test.ts new file mode 100644 index 00000000000..58dbb38f4b8 --- /dev/null +++ b/packages/api-headless-cms-scheduler/__tests__/actionHandlers.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useHandler } from "~tests/mocks/context/useHandler.js"; +import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; +import { createMockScheduleClient } from "./mocks/scheduleClient.js"; +import { createHeadlessCmsScheduler } from "~/index.js"; +import { ScheduleEntryActionUseCase } from "~/features/ScheduleEntryAction/index.js"; +import { createMockTargetModelPlugins, MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { ListScheduledActionsUseCase, ExecuteScheduledActionUseCase } from "@webiny/api-scheduler"; + +describe("Action Handlers", () => { + let context: CmsContext; + + beforeEach(async () => { + const contextHandler = useHandler({ + plugins: [createHeadlessCmsScheduler(), createMockTargetModelPlugins()], + getScheduleClient: () => { + return createMockScheduleClient(); + } + }); + context = await contextHandler.handler(); + }); + + it("should publish and unpublish an entry", async () => { + const container = context.container; + + const getModel = container.resolve(GetModelUseCase); + const createEntry = container.resolve(CreateEntryUseCase); + const getEntryById = container.resolve(GetEntryByIdUseCase); + const scheduleEntryAction = container.resolve(ScheduleEntryActionUseCase); + const listScheduledActions = container.resolve(ListScheduledActionsUseCase); + const executeScheduledAction = container.resolve(ExecuteScheduledActionUseCase); + + const modelResult = await getModel.execute(MOCK_TARGET_MODEL_ID); + const entryResult = await createEntry.execute(modelResult.value, { + title: "First entry" + }); + + expect(entryResult.value.status).toBe("draft"); + + // Schedule entry for publishing + const publishActionResult = await scheduleEntryAction.execute({ + modelId: MOCK_TARGET_MODEL_ID, + targetId: entryResult.value.id, + actionType: "Publish", + scheduleOn: new Date(Date.now() + 100000) + }); + + // Assert scheduled actions + const actionsResponse = await listScheduledActions.execute({ + where: { namespace_startsWith: "Cms/Entry" } + }); + + expect(actionsResponse.value.items).toHaveLength(1); + expect(actionsResponse.value.items[0].title).toBe("First entry"); + + // Execute actions + const scheduledAction = publishActionResult.value; + await executeScheduledAction.execute(scheduledAction.id); + + // Assert entry published + const publishedEntryResult = await getEntryById.execute( + modelResult.value, + entryResult.value.id + ); + + expect(publishedEntryResult.value.status).toBe("published"); + + // Schedule entry for unpublishing + const unpublishActionResult = await scheduleEntryAction.execute({ + modelId: MOCK_TARGET_MODEL_ID, + targetId: entryResult.value.id, + actionType: "Unpublish", + scheduleOn: new Date(Date.now() + 1000000) + }); + + // Execute action handler + await executeScheduledAction.execute(unpublishActionResult.value.id); + + const unpublishedEntryResult = await getEntryById.execute( + modelResult.value, + entryResult.value.id + ); + + expect(unpublishedEntryResult.value.status).toBe("unpublished"); + }); +}); diff --git a/packages/api-headless-cms-scheduler/__tests__/graphql/schema.test.ts b/packages/api-headless-cms-scheduler/__tests__/graphql/schema.test.ts deleted file mode 100644 index 441f85764f5..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/graphql/schema.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - cancelScheduleSchema, - createScheduleSchema, - getScheduleSchema, - listScheduleSchema, - updateScheduleSchema -} from "~/graphql/schema.js"; -import { type ISchedulerInput, ScheduleType } from "~/scheduler/types.js"; - -interface IExpectedSchemaInput { - id: string; - modelId: string; - input: ISchedulerInput; -} - -interface IExpectedCancelSchemaInput { - id: string; - modelId: string; -} - -describe("graphql/schema", () => { - it("getScheduleSchema: accepts valid input and returns expected data", async () => { - const input = { - modelId: "model", - id: "123" - }; - const result = await getScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(input); - } - }); - it("getScheduleSchema: rejects missing fields", async () => { - const result1 = await getScheduleSchema.safeParseAsync({ modelId: "model" }); - const result2 = await getScheduleSchema.safeParseAsync({ id: "123" }); - expect(result1.success).toBe(false); - expect(result2.success).toBe(false); - }); - - it("listScheduleSchema: accepts valid input and returns expected data", async () => { - const input = { - modelId: "model", - where: { targetId: "t1" }, - sort: ["title_ASC"], - limit: 10, - after: "cursor" - }; - const result = await listScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(input); - } - }); - it("listScheduleSchema: rejects missing modelId", async () => { - const result = await listScheduleSchema.safeParseAsync({}); - expect(result.success).toBe(false); - }); - it("listScheduleSchema: rejects invalid sort values", async () => { - const result = await listScheduleSchema.safeParseAsync({ - modelId: "model", - where: {}, - sort: ["badformat"] - }); - expect(result.success).toBe(false); - }); - it("listScheduleSchema: accepts valid sort values and returns expected data", async () => { - const input = { - modelId: "model", - where: {}, - sort: ["title_ASC", "createdOn_DESC"] - }; - const result = await listScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual({ ...input }); - } - }); - - it("createScheduleSchema: accepts immediate input and returns expected data", async () => { - const input = { - modelId: "model", - id: "123", - input: { - immediately: true, - type: "publish" - } - }; - const result = await createScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(input); - } - }); - it("createScheduleSchema: accepts dateOn input and returns expected data", async () => { - const date = new Date(); - const input: IExpectedSchemaInput = { - modelId: "model", - id: "123", - input: { - scheduleOn: date, - type: ScheduleType.unpublish - } - }; - const result = await createScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - - expect(result.data).toEqual({ - ...input, - input: { - ...input.input, - scheduleOn: date - } - }); - // @ts-expect-error - expect(result.data.input.scheduleOn).toBeInstanceOf(Date); - }); - it("createScheduleSchema: rejects missing input fields", async () => { - const result = await createScheduleSchema.safeParseAsync({ - modelId: "model", - id: "123", - input: { - type: "publish" - } - }); - expect(result.success).toBe(false); - }); - - it("updateScheduleSchema: accepts immediate input and returns expected data", async () => { - const input = { - modelId: "model", - id: "123", - input: { - immediately: true, - type: "publish" - } - }; - const result = await updateScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(input); - } - }); - it("updateScheduleSchema: accepts dateOn input and returns expected data", async () => { - const date = new Date(); - const input: IExpectedSchemaInput = { - modelId: "model", - id: "123", - input: { - scheduleOn: date, - type: ScheduleType.unpublish - } - }; - const result = await updateScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual({ - ...input, - input: { - ...input.input, - scheduleOn: date - } - }); - expect(result.data.input.scheduleOn).toBeInstanceOf(Date); - } - }); - it("updateScheduleSchema: rejects missing input fields", async () => { - const result = await updateScheduleSchema.safeParseAsync({ - modelId: "model", - id: "123", - input: { - type: "publish" - } - }); - expect(result.success).toBe(false); - }); - - it("cancelScheduleSchema: accepts valid input and returns expected data", async () => { - const input: IExpectedCancelSchemaInput = { - modelId: "model", - id: "123" - }; - const result = await cancelScheduleSchema.safeParseAsync(input); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual(input); - } - }); - it("cancelScheduleSchema: rejects missing fields", async () => { - const result1 = await cancelScheduleSchema.safeParseAsync({ modelId: "model" }); - const result2 = await cancelScheduleSchema.safeParseAsync({ id: "123" }); - expect(result1.success).toBe(false); - expect(result2.success).toBe(false); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts deleted file mode 100644 index d5f7767cda6..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/handler/Handler.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; -import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import type { CmsContext, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; -import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; -import { ProcessRecordsUseCase } from "~/features/ProcessRecords/ProcessRecordsUseCase.js"; -import { - ProcessRecordsUseCase as ProcessRecordsAbstraction, - RecordAction -} from "~/features/ProcessRecords/index.js"; -import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; -import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; -import { SchedulerFactory } from "~/features/Scheduler/abstractions"; - -const createEventScheduleRecordId = (targetId: string): string => { - return `${createScheduleRecordIdWithVersion(targetId)}`; -}; - -describe("Handler", () => { - const targetId = "target-id#0001"; - - let context: CmsContext; - - beforeEach(async () => { - const contextHandler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - context = await contextHandler.handler(); - }); - - const createScheduleEntry = async ( - values: Omit - ): Promise> => { - const getModel = context.container.resolve(GetModelUseCase); - const createEntry = context.container.resolve(CreateEntryUseCase); - - const modelResult = await getModel.execute(SCHEDULE_MODEL_ID); - const model = modelResult.value; - - const res = await createEntry.execute(model, { - id: createScheduleRecordId(values.targetId), - ...values, - targetModelId: MOCK_TARGET_MODEL_ID - }); - - return res.value as CmsEntry; - }; - - it("should fail to handle due to missing schedule entry", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual( - `Entry "${createEventScheduleRecordId(targetId)}" was not found!` - ); - expect(ex.code).toEqual("Cms/Entry/NotFound"); - } - }); - - it("should fail to find action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.register(UnpublishRecordAction); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual( - `No action found for schedule record ID: wby-schedule-target-id-0001#0001` - ); - } - }); - - it("should handle action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.register(PublishRecordAction); - - // Resolve and execute - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const targetEntry = await context.cms.createEntry(targetModel, { - id: "target-id", - title: "Test Entry" - }); - - expect(targetEntry.id).toEqual(targetId); - - const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - const schedulerFactory = testContainer.resolve(SchedulerFactory); - const scheduler = schedulerFactory.useModel(targetModel); - - const getScheduleEntry = await scheduler.getScheduled(createScheduleRecordId(targetId)); - - expect(getScheduleEntry).toMatchObject({ - id: expect.any(String), - targetId, - model: targetModel, - title: "Test Entry", - publishOn: expect.any(Date), - unpublishOn: undefined, - type: ScheduleType.publish - }); - - await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - const [afterDeleteScheduledEntry] = await context.cms.getEntriesByIds(scheduleModel, [ - scheduleEntry.id - ]); - - expect(afterDeleteScheduledEntry).toBeUndefined(); - - const [afterActionTargetEntry] = await context.cms.getPublishedEntriesByIds(targetModel, [ - targetId - ]); - expect(afterActionTargetEntry).toMatchObject({ - id: targetId, - values: { - title: "Test Entry" - }, - status: "published" - }); - }); - - it("should throw an error while handling action", async () => { - const testContainer = context.container.createChildContainer(); - // Register a use case without actions - testContainer.register(ProcessRecordsUseCase); - testContainer.registerInstance(RecordAction, { - canHandle: () => true, - async handle(): Promise { - throw new Error("Unknown error."); - } - }); - - const processRecords = testContainer.resolve(ProcessRecordsAbstraction); - - const targetModel = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const targetEntry = await context.cms.createEntry(targetModel, { - id: "target-id", - title: "Test Entry" - }); - expect(targetEntry.id).toEqual(targetId); - - const scheduleModel = await context.cms.getModel(SCHEDULE_MODEL_ID); - const scheduleEntry = await createScheduleEntry({ - targetId, - type: ScheduleType.publish, - title: "Test Entry", - scheduledOn: dateToISOString(new Date()), - scheduledBy: context.security.getIdentity() - }); - - expect(scheduleEntry.entryId).toEqual(`${createScheduleRecordId(targetId)}`); - - try { - const result = await processRecords.execute({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createEventScheduleRecordId(targetId), - scheduleOn: new Date().toISOString() - } - }); - - if (result.isFail()) { - throw result.error; - } - - expect(result).toEqual("SHOULD NOT REACH HERE."); - } catch (ex) { - expect(ex.message).toEqual("Unknown error."); - } - - const afterActionErrorScheduleEntry = await context.cms.getEntryById( - scheduleModel, - scheduleEntry.id - ); - expect(afterActionErrorScheduleEntry).toMatchObject({ - id: scheduleEntry.id, - values: { - title: "Test Entry", - error: "Unknown error." - } - }); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts deleted file mode 100644 index 274243385e5..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/handler/actions/PublishHandlerAction.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { PublishRecordAction } from "~/features/ProcessRecords/actions/PublishRecordAction.js"; -import { RecordAction } from "~/features/ProcessRecords/index.js"; - -describe("PublishHandlerAction", () => { - it("should only handle publish action", async () => { - // @ts-expect-error No deps provided; we only test a `canHandle`. - const action = new PublishRecordAction(); - - expect(action.canHandle({ type: ScheduleType.publish })).toBe(true); - expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(false); - }); - - it("should throw an error if target entry does not exist", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - try { - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - expect(result).toEqual("Should not reach here."); - } catch (ex) { - expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); - } - }); - - it("should publish an entry which is not published yet", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - }); - - it("should republish an entry which is already published", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [rePublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(rePublishedEntry.id).toBe("target-id#0001"); - const publishedDate = new Date(publishedEntry.lastPublishedOn!); - const rePublishedDate = new Date(rePublishedEntry.lastPublishedOn!); - expect(rePublishedDate > publishedDate).toBeTrue(); - }); - - it("should publish a new entry revision if the existing published revision is different", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(PublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - - expect(action).toBeInstanceOf(PublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - await context.cms.getPublishedEntriesByIds(model, ["target-id#0001"]); - - const newEntryRevision = await context.cms.createEntryRevisionFrom( - model, - "target-id#0001", - { - title: "Test Entry - Updated" - } - ); - expect(newEntryRevision).toMatchObject({ - id: "target-id#0002", - values: { - title: "Test Entry - Updated" - } - }); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0002", - model - }); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, ["target-id"]); - expect(publishedEntry.id).toBe("target-id#0002"); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts deleted file mode 100644 index c25df5c6dc3..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/handler/actions/UnpublishHandlerAction.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { useHandler } from "~tests/mocks/context/useHandler.js"; -import { createMockScheduleClient } from "~tests/mocks/scheduleClient.js"; -import { MOCK_TARGET_MODEL_ID } from "~tests/mocks/targetModel.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { describe, expect, it, vi } from "vitest"; -import { UnpublishRecordAction } from "~/features/ProcessRecords/actions/UnpublishRecordAction.js"; -import { RecordAction } from "~/features/ProcessRecords/index.js"; - -describe("UnpublishHandlerAction", () => { - it("should only handle unpublish action", async () => { - // @ts-expect-error No deps provided; we only test a `canHandle`. - const action = new UnpublishRecordAction(); - - expect(action.canHandle({ type: ScheduleType.publish })).toBe(false); - expect(action.canHandle({ type: ScheduleType.unpublish })).toBe(true); - }); - - it("should throw an error if target entry does not exist", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - try { - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - expect(result).toEqual("Should not reach here."); - } catch (ex) { - expect(ex.message).toBe(`Entry "target-id#0001" was not found!`); - } - }); - - it("should do nothing if entry is not published", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - console.warn = vi.fn(); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - expect(console.warn).toHaveBeenCalledWith( - `Entry "target-id#0001" is not published, nothing to unpublish.` - ); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry).toBeUndefined(); - }); - - it("should unpublish an entry if it matches", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - await context.cms.publishEntry(model, "target-id#0001"); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(unpublishedEntry).toBeUndefined(); - }); - - it("should unpublish entry even if it does not match the target ID (revision).", async () => { - const handler = useHandler({ - getScheduleClient: () => { - return createMockScheduleClient(); - } - }); - const context = await handler.handler(); - - const testContainer = context.container.createChildContainer(); - testContainer.register(UnpublishRecordAction); - - const action = testContainer.resolveAll(RecordAction)[0]; - expect(action).toBeInstanceOf(UnpublishRecordAction); - - const model = await context.cms.getModel(MOCK_TARGET_MODEL_ID); - - const entry = await context.cms.createEntry(model, { - id: "target-id", - title: "Test Entry" - }); - expect(entry.id).toEqual("target-id#0001"); - - await context.cms.publishEntry(model, "target-id#0001"); - - const [publishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(publishedEntry.id).toBe("target-id#0001"); - - const newEntryRevision = await context.cms.createEntryRevisionFrom( - model, - publishedEntry.id, - { - title: "Test Entry - Updated" - } - ); - - expect(newEntryRevision.id).toEqual("target-id#0002"); - - await context.cms.publishEntry(model, "target-id#0002"); - - const [publishedOverwriteEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0002" - ]); - - expect(publishedOverwriteEntry.id).toBe("target-id#0002"); - - // @ts-expect-error We only want to test the base execution. - const result = await action.handle({ - targetId: "target-id#0001", - model - }); - - expect(result).toBeUndefined(); - - // @ts-expect-error We only want to test the base execution. - await action.handle({ - targetId: "target-id#0001", - model - }); - const [unpublishedEntry] = await context.cms.getPublishedEntriesByIds(model, [ - "target-id#0001" - ]); - - expect(unpublishedEntry).toBeUndefined(); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts b/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts deleted file mode 100644 index e9a65365540..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/handler/eventHandler.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { RawEventHandler } from "@webiny/handler-aws/raw/index.js"; -import { createScheduledCmsActionEventHandler } from "~/handler/index.js"; -import { registry } from "@webiny/handler-aws/registry.js"; -import type { LambdaContext } from "@webiny/handler-aws/types.js"; -import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { createScheduleRecordId } from "~/scheduler/createScheduleRecordId.js"; -import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; - -describe("Scheduler Event Handler", () => { - const lambdaContext = {} as LambdaContext; - it("should trigger handle an event which matches scheduled event", async () => { - const eventHandler = createScheduledCmsActionEventHandler(); - - expect(eventHandler).toBeInstanceOf(RawEventHandler); - - const event: IWebinyScheduledCmsActionEvent = { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { - id: createScheduleRecordId("target-id#0001"), - scheduleOn: new Date().toISOString() - } - }; - const sourceHandler = registry.getHandler(event, lambdaContext); - - expect(sourceHandler).toMatchObject({ - name: "handler-aws-event-bridge-scheduled-cms-action-event" - }); - expect(sourceHandler.canUse(event, lambdaContext)).toBe(true); - - /** - * Should break because there are no contexts loaded. - */ - const result = await sourceHandler.handle({ - params: { - plugins: [eventHandler] - }, - event, - context: lambdaContext - }); - /** - * We are expecting an error because the context is not set up properly - we dont need it to be set up. - */ - expect(result).toEqual({ - body: '{"message":"No registration found for SchedulerFactory"}', - headers: { - "access-control-allow-headers": "*", - "access-control-allow-methods": "POST", - "access-control-allow-origin": "*", - "cache-control": "no-store", - connection: "keep-alive", - "content-length": "56", - "content-type": "text/plain; charset=utf-8", - date: expect.toBeDateString() - }, - isBase64Encoded: false, - statusCode: 500 - }); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/cms.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/cms.ts deleted file mode 100644 index 0fd27d8e0d7..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/cms.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { vi } from "vitest"; -import type { ScheduleExecutorCms } from "~/scheduler/ScheduleExecutor.js"; -import type { ScheduleFetcherCms } from "~/scheduler/ScheduleFetcher.js"; - -export const createMockCms = ( - cms?: Partial -): ScheduleExecutorCms & ScheduleFetcherCms => { - return { - listLatestEntries: vi.fn(), - publishEntry: vi.fn(), - unpublishEntry: vi.fn(), - updateEntry: vi.fn(), - createEntry: vi.fn(), - deleteEntry: vi.fn(), - getEntryById: vi.fn(), - ...cms - }; -}; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/helpers.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/helpers.ts index 0fdda22b2a0..7b582355ba4 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/helpers.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/helpers.ts @@ -47,10 +47,6 @@ export const createPermissions = (permissions?: PermissionsArg[]): PermissionsAr }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US", "de-DE"] } ]; }; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/plugins.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/plugins.ts index 634939a78d4..cd2abc2faec 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/plugins.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/plugins.ts @@ -4,24 +4,20 @@ import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api- import { createTenancyAndSecurity } from "./tenancySecurity"; import type { PermissionsArg } from "./helpers"; import { createPermissions } from "./helpers"; -import type { ContextPlugin } from "@webiny/api"; -import type { ScheduleContext } from "~/types.js"; import type { Plugin, PluginCollection } from "@webiny/plugins/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; -import { createHeadlessCmsScheduler } from "~/index.js"; import type { SchedulerClient, SchedulerClientConfig } from "@webiny/aws-sdk/client-scheduler/index.js"; import { createSchedulerManifestPlugin } from "~tests/mocks/schedulerManifestPlugin.js"; -import { createMockTargetModelPlugins } from "~tests/mocks/targetModel.js"; import apiKeyAuthentication from "@webiny/api-core/legacy/security/plugins/apiKeyAuthentication.js"; import apiKeyAuthorization from "@webiny/api-core/legacy/security/plugins/apiKeyAuthorization.js"; -import type { ApiKey } from "@webiny/api-core/types/security.js"; import { createApiCore } from "@webiny/api-core"; import type { IdentityData } from "@webiny/api-core/features/security/IdentityContext/index.js"; import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js"; +import { createScheduler } from "@webiny/api-scheduler"; export interface CreateHandlerCoreParams { getScheduleClient: (config?: SchedulerClientConfig) => Pick; @@ -42,15 +38,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { name: "Root", parent: null }; - const locale = "en-US"; - const { - permissions, - identity, - plugins = [], - topPlugins = [], - bottomPlugins = [], - setupTenancyAndSecurityGraphQL - } = params; + const { permissions, identity, plugins = [], topPlugins = [], bottomPlugins = [] } = params; const apiCoreStorage = getStorageOps("apiCore"); const cmsStorage = getStorageOps("cms"); @@ -58,9 +46,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { return { storageOperations: cmsStorage.storageOperations, tenant, - locale, plugins: [ - createMockTargetModelPlugins(), topPlugins, ...cmsStorage.plugins, createApiCore({ @@ -68,40 +54,10 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { testProjectLicense: createTestWcpLicense() }), ...createTenancyAndSecurity({ - setupGraphQL: setupTenancyAndSecurityGraphQL, permissions: createPermissions(permissions), identity }), createSchedulerManifestPlugin(), - { - type: "context", - name: "context-security-tenant", - async apply(context) { - context.security.getApiKeyByToken = async ( - token: string - ): Promise => { - if (!token || token !== "aToken") { - return null; - } - const apiKey = "a1234567890"; - return { - id: apiKey, - name: apiKey, - tenant: tenant.id, - permissions: identity?.permissions || [], - token, - createdBy: { - id: "test", - displayName: "test", - type: "admin" - }, - description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION - }; - }; - } - } as ContextPlugin, apiKeyAuthentication({ identityType: "api-key" }), apiKeyAuthorization({ identityType: "api-key" }), createHeadlessCmsContext({ @@ -110,7 +66,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { createHeadlessCmsGraphQL(), plugins, graphQLHandlerPlugins(), - createHeadlessCmsScheduler({ + createScheduler({ getClient: config => { return params.getScheduleClient(config); } diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts index 46d43e0e387..5ce2b90a789 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts @@ -1,10 +1,10 @@ import type { Plugin } from "@webiny/plugins/Plugin"; import { ContextPlugin } from "@webiny/api"; import { BeforeHandlerPlugin } from "@webiny/handler"; -import type { ScheduleContext } from "~/types.js"; import { SecurityPermission } from "@webiny/api-core/types/security"; import { IdentityData } from "@webiny/api-core/features/IdentityContext"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; interface Config { permissions: SecurityPermission[]; @@ -19,7 +19,7 @@ export const defaultIdentity: IdentityData = { export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plugin[] => { return [ - new ContextPlugin(async context => { + new ContextPlugin(async context => { await context.tenancy.createTenant({ id: "root", name: "Root", @@ -52,7 +52,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu tags: [] }); }), - new ContextPlugin(async context => { + new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", name: "Root", @@ -72,7 +72,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu return permissions || [{ name: "*" }]; }); }), - new BeforeHandlerPlugin(context => { + new BeforeHandlerPlugin(context => { const { headers = {} } = context.request || {}; if (headers["authorization"]) { return context.security.authenticate(headers["authorization"]); diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts index 79d19c82789..3f277e5b662 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/useHandler.ts @@ -34,7 +34,6 @@ export const useHandler = (params: CreateHandlerCoreParams plugins, identity: params.identity || defaultIdentity, tenant: core.tenant, - locale: core.locale, elasticsearch: elasticsearchClient, handler: (input?: CmsHandlerEvent) => { const payload: CmsHandlerEvent = { diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/fetcher.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/fetcher.ts deleted file mode 100644 index 3a6a69af5e5..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/fetcher.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { vi } from "vitest"; -import type { IScheduleFetcher } from "~/scheduler/types.js"; - -export const createMockFetcher = (input?: Partial): IScheduleFetcher => { - return { - getScheduled: vi.fn(), - listScheduled: vi.fn(), - ...input - }; -}; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerManifestPlugin.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerManifestPlugin.ts index c4504fa3c25..972154d90bd 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerManifestPlugin.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerManifestPlugin.ts @@ -1,10 +1,10 @@ import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { PutCommand } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import type { ScheduleContext } from "~/types.js"; import { ContextPlugin } from "@webiny/api"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export const createSchedulerManifestPlugin = () => { - return new ContextPlugin(async context => { + return new ContextPlugin(async context => { const manifest = { lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-scheduler-function", roleArn: "arn:aws:iam::123456789012:role/my-scheduler-role" diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerModel.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerModel.ts deleted file mode 100644 index 01e12ad0091..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/schedulerModel.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { createSchedulerModel } from "~/scheduler/model.js"; - -export const createMockSchedulerModel = (input?: Partial): CmsModel => { - const model = createSchedulerModel(); - return { - ...model.contentModel, - webinyVersion: "0.0.0", - tenant: "root", - locale: "en-US", - ...input - }; -}; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/security.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/security.ts deleted file mode 100644 index 952b933ca8b..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/security.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; - -export const createMockSecurity = ( - input?: Partial> -): Pick => { - const getIdentity = createMockGetIdentity(); - return { - // @ts-expect-error - getIdentity, - withoutAuthorization: (cb: () => Promise) => { - return cb(); - }, - ...input - }; -}; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/service.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/service.ts deleted file mode 100644 index a7ff87a4207..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/service.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { vi } from "vitest"; -import type { ISchedulerService } from "~/service/types.js"; - -export const createMockService = (input?: Partial): ISchedulerService => { - return { - create: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - exists: vi.fn(), - ...input - }; -}; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts index ea6d7c8b119..02b3c36016a 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts @@ -30,7 +30,6 @@ export const createMockTargetModel = (): CmsModel => { savedOn: new Date().toISOString(), webinyVersion: "0.0.0", tenant: "root", - locale: "en-US", titleFieldId: "title" }; }; diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts deleted file mode 100644 index d66942e1734..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleExecutor.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ScheduleExecutor } from "~/scheduler/ScheduleExecutor.js"; -import { createMockFetcher } from "~tests/mocks/fetcher.js"; -import { type IScheduleRecord, ScheduleType } from "~/scheduler/types.js"; -import { createScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; - -describe("ScheduleExecutor", () => { - it("should execute not find action for publishing", async () => { - const fetcher = createMockFetcher({ - async getScheduled(targetId: string): Promise { - return createScheduleRecord({ - id: `schedule-record-id-${targetId}`, - targetId, - scheduledOn: new Date(), - type: ScheduleType.publish, - title: `Scheduled for ${targetId}`, - scheduledBy: createMockGetIdentity()(), - model: {} as any - }); - } - }); - const executor = new ScheduleExecutor({ - actions: [], - fetcher - }); - - await expect( - executor.schedule("target-id#0001", { - type: ScheduleType.publish, - scheduleOn: new Date() - }) - ).rejects.toThrow(`No action found for input type "${ScheduleType.publish}"`); - - await expect(executor.cancel("target-id#0001")).rejects.toThrow( - `No action found for input type "${ScheduleType.publish}"` - ); - }); - - it("should execute not find action for unpublishing", async () => { - const fetcher = createMockFetcher({ - async getScheduled(targetId: string): Promise { - return createScheduleRecord({ - id: `schedule-record-id-${targetId}`, - targetId, - scheduledOn: new Date(), - type: ScheduleType.unpublish, - title: `Scheduled for ${targetId}`, - scheduledBy: createMockGetIdentity()(), - model: {} as any - }); - } - }); - const executor = new ScheduleExecutor({ - actions: [], - fetcher - }); - - await expect( - executor.schedule("target-id#0001", { - type: ScheduleType.unpublish, - scheduleOn: new Date() - }) - ).rejects.toThrow(`No action found for input type "${ScheduleType.unpublish}"`); - - await expect(executor.cancel("target-id#0001")).rejects.toThrow( - `No action found for input type "${ScheduleType.unpublish}"` - ); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts deleted file mode 100644 index e9dc12a02ca..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/ScheduleFetcher.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ScheduleFetcher } from "~/scheduler/ScheduleFetcher.js"; -import type { - CmsEntryMeta, - CmsIdentity, - CmsModel, - HeadlessCms -} from "@webiny/api-headless-cms/types/index.js"; -import type { ISchedulerListParams } from "~/scheduler/types.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { describe, expect, it, vi } from "vitest"; - -describe("ScheduleFetcher", () => { - const targetModel: CmsModel = { modelId: "targetModel", titleFieldId: "title" } as any; - const schedulerModel: CmsModel = { modelId: "schedulerModel", titleFieldId: "title" } as any; - const entryMeta: CmsEntryMeta = { hasMoreItems: false, totalCount: 1, cursor: null }; - - function createMockCms(overrides: Partial = {}) { - return { - getEntryById: vi.fn(), - listLatestEntries: vi.fn(), - ...overrides - } as unknown as HeadlessCms; - } - - it("getScheduled returns a record if found", async () => { - const identity: CmsIdentity = { - id: "user-1", - displayName: "User 1", - type: "admin" - }; - const mockedEntry = { - id: "schedule-1", - values: { - type: "publish", - title: "Test Entry", - targetModelId: targetModel.modelId, - targetId: "target-1", - scheduledOn: dateToISOString(new Date()), - scheduledBy: identity - }, - savedOn: new Date().toISOString(), - savedBy: identity - }; - const cms = createMockCms({ - getEntryById: vi.fn().mockResolvedValue(mockedEntry) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const result = await fetcher.getScheduled("target-1"); - expect(result).not.toBeNull(); - expect(result?.targetId).toBe("target-1"); - expect(result?.title).toBe("Test Entry"); - }); - - it("getScheduled returns null if not found", async () => { - const cms = createMockCms({ - getEntryById: vi.fn().mockRejectedValue(new NotFoundError("not found")) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const result = await fetcher.getScheduled("target-1"); - expect(result).toBeNull(); - }); - - it("getScheduled throws on unknown error", async () => { - const cms = createMockCms({ - getEntryById: vi.fn().mockRejectedValue(new Error("unknown")) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - await expect(fetcher.getScheduled("target-1")).rejects.toThrow("unknown"); - }); - - it("listScheduled returns data and meta", async () => { - const entry = { - id: "schedule-1", - values: { - type: "publish", - title: "Test Entry", - targetId: "target-1", - dateOn: new Date().toISOString(), - scheduledBy: { id: "user-1" } - }, - savedOn: new Date().toISOString(), - savedBy: { id: "user-1" } - }; - const cms = createMockCms({ - listLatestEntries: vi.fn().mockResolvedValue([[entry], entryMeta]) - }); - const fetcher = new ScheduleFetcher({ cms, targetModel, schedulerModel }); - const params: ISchedulerListParams = { - where: {}, - sort: undefined, - limit: undefined, - after: undefined - }; - const result = await fetcher.listScheduled(params); - expect(result.data.length).toBe(1); - expect(result.data[0].title).toBe("Test Entry"); - expect(result.meta).toEqual(entryMeta); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/Scheduler.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/Scheduler.test.ts deleted file mode 100644 index 1a499fb8dbf..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/Scheduler.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Scheduler } from "~/scheduler/Scheduler.js"; -import { type ISchedulerInput, ScheduleType } from "~/scheduler/types.js"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Scheduler", () => { - const fetcher = { - getScheduled: vi.fn(), - listScheduled: vi.fn() - }; - const executor = { - schedule: vi.fn(), - cancel: vi.fn() - }; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("should create an instance of Scheduler", () => { - const result = new Scheduler({ fetcher, executor }); - - expect(result).toBeInstanceOf(Scheduler); - }); - - it("should call fetcher.getScheduled when fetching a schedule", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - const expectedResult = { id: "schedule-id#0001", targetId, dateOn: new Date() }; - fetcher.getScheduled.mockResolvedValue(expectedResult); - const result = await scheduler.getScheduled(targetId); - expect(fetcher.getScheduled).toHaveBeenCalledWith(targetId); - expect(result).toEqual(expectedResult); - }); - - it("should call fetcher.listScheduled when listing schedules", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const expectedResult = [ - { id: "schedule-id#0001", targetId: "target-id#0001", dateOn: new Date() } - ]; - fetcher.listScheduled.mockResolvedValue(expectedResult); - const result = await scheduler.listScheduled({ - sort: undefined, - after: undefined, - limit: undefined, - where: {} - }); - expect(fetcher.listScheduled).toHaveBeenCalled(); - expect(result).toEqual(expectedResult); - }); - - it("should call executor.schedule when scheduling a task", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - const input: ISchedulerInput = { - type: ScheduleType.publish, - scheduleOn: new Date() - }; - await scheduler.schedule(targetId, input); - expect(executor.schedule).toHaveBeenCalledWith(targetId, input); - }); - - it("should call executor.cancel when canceling a scheduled task", async () => { - const scheduler = new Scheduler({ fetcher, executor }); - const targetId = "target-id#0001"; - await scheduler.cancel(targetId); - expect(executor.cancel).toHaveBeenCalledWith(targetId); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts deleted file mode 100644 index 11310ae0247..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/PublishScheduleAction.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createMockService } from "~tests/mocks/service.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { PublishScheduleAction } from "~/scheduler/actions/PublishScheduleAction.js"; -import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; -import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { SchedulerService } from "~/service/SchedulerService.js"; -import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; -import { mockClient } from "aws-sdk-client-mock"; -import { createMockFetcher } from "~tests/mocks/fetcher"; - -describe("PublishScheduleAction", () => { - const service = createMockService(); - const fetcher = createMockFetcher(); - const getIdentity = createMockGetIdentity(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - - it("should schedule a publish action immediately", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - immediately: true, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: expect.any(Date), - unpublishOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - }); - - it("should publish an entry immediately if the scheduleOn is in the past", async () => { - const updateEntryMock = vi.fn(async () => { - return {} as CmsEntry; - }); - const cms = createMockCms({ - updateEntry: updateEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const scheduleOn = new Date(Date.now() - 1000000); - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: expect.any(Date), - unpublishOn: undefined, - dateOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - - expect(updateEntryMock).toHaveBeenCalledTimes(1); - expect(updateEntryMock).toHaveBeenCalledWith(targetModel, "target-id#0002", { - firstPublishedBy: getIdentity(), - firstPublishedOn: scheduleOn.toISOString(), - lastPublishedBy: getIdentity(), - lastPublishedOn: scheduleOn.toISOString() - }); - }); - - it("should schedule a publish action for a future date", async () => { - const client = mockClient(SchedulerClient); - client.on(CreateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - const service = new SchedulerService({ - getClient: () => client, - config: { - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:my-function", - roleArn: "arn:aws:iam::123456789012:role/my-role" - } - }); - const scheduleOn = new Date(Date.now() + 1000000); - - const crateEntryMock = vi.fn(async () => { - const entry: Pick, "id" | "values" | "savedBy"> = { - id: createScheduleRecordIdWithVersion(`target-id#0002`), - values: { - targetId: "target-id#0002", - type: ScheduleType.publish, - scheduledOn: dateToISOString(scheduleOn), - title: "Test Entry", - targetModelId: targetModel.modelId, - scheduledBy: getIdentity() - }, - savedBy: getIdentity() - }; - return entry as CmsEntry; - }); - const cms = createMockCms({ - // @ts-expect-error - createEntry: crateEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async publishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new PublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.publish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: scheduleOn, - unpublishOn: undefined, - type: ScheduleType.publish, - title: "Test Entry" - }); - - expect(crateEntryMock).toHaveBeenCalledTimes(1); - expect(crateEntryMock).toHaveBeenCalledWith(schedulerModel, { - id: createScheduleRecordId(`target-id#0002`), - scheduledBy: getIdentity(), - scheduledOn: dateToISOString(scheduleOn), - targetId: "target-id#0002", - targetModelId: "targetModel", - title: "Test Entry", - type: ScheduleType.publish - }); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts deleted file mode 100644 index e569654859c..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/actions/UnpublishScheduleAction.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { createMockService } from "~tests/mocks/service.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { UnpublishScheduleAction } from "~/scheduler/actions/UnpublishScheduleAction.js"; -import type { CmsEntry, CmsEntryValues } from "@webiny/api-headless-cms/types/index.js"; -import { ScheduleRecord } from "~/scheduler/ScheduleRecord.js"; -import { type IScheduleEntryValues, ScheduleType } from "~/scheduler/types.js"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { mockClient } from "aws-sdk-client-mock"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { CreateScheduleCommand, SchedulerClient } from "@webiny/aws-sdk/client-scheduler/index.js"; -import { SchedulerService } from "~/service/SchedulerService.js"; -import { createMockFetcher } from "~tests/mocks/fetcher.js"; -import { describe, expect, it, vi } from "vitest"; - -describe("UnpublishScheduleAction", () => { - const service = createMockService(); - const fetcher = createMockFetcher(); - const getIdentity = createMockGetIdentity(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - - it("should unpublish an entry immediately if input.immediately is true", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - immediately: true, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: expect.any(Date), - type: ScheduleType.unpublish, - title: "Test Entry" - }); - }); - - it("should unpublish an entry immediately if the scheduleOn is in the past", async () => { - const cms = createMockCms({ - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const scheduleOn = new Date(Date.now() - 1000000); - const result = await action.schedule({ - input: { - scheduleOn, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: scheduleOn, - type: ScheduleType.unpublish, - title: "Test Entry" - }); - }); - - it("should schedule an unpublish action for a future date", async () => { - const client = mockClient(SchedulerClient); - client.on(CreateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config: { - roleArn: "arn:aws:iam::123456789012:role/scheduler-role", - lambdaArn: "arn:aws:lambda:us-east-1:123456789012:function:scheduler-lambda" - } - }); - - const scheduleOn = new Date(Date.now() + 1000000); - - const createEntryMock = vi.fn(async () => { - const entry: Pick, "id" | "values" | "savedBy"> = { - id: createScheduleRecordIdWithVersion(`target-id#0002`), - values: { - targetId: "target-id#0002", - type: ScheduleType.unpublish, - scheduledOn: dateToISOString(scheduleOn), - title: "Test Entry", - targetModelId: targetModel.modelId, - scheduledBy: getIdentity() - }, - savedBy: getIdentity() - }; - return entry; - }); - const cms = createMockCms({ - // @ts-expect-error - createEntry: createEntryMock, - async getEntryById() { - return { - values: { - title: "Test Entry", - savedBy: getIdentity() - } - } as CmsEntry; - }, - async unpublishEntry() { - return { - savedBy: getIdentity(), - savedOn: new Date().toISOString() - } as CmsEntry; - } - }); - - const action = new UnpublishScheduleAction({ - service, - getIdentity, - targetModel, - schedulerModel, - cms, - fetcher - }); - - const result = await action.schedule({ - input: { - scheduleOn: scheduleOn, - type: ScheduleType.unpublish - }, - targetId: "target-id#0002", - scheduleRecordId: createScheduleRecordIdWithVersion(`target-id#0002`) - }); - - expect(result).toBeInstanceOf(ScheduleRecord); - expect(result).toEqual({ - id: createScheduleRecordIdWithVersion(`target-id#0002`), - targetId: "target-id#0002", - model: targetModel, - scheduledBy: getIdentity(), - publishOn: undefined, - unpublishOn: scheduleOn, - type: ScheduleType.unpublish, - title: "Test Entry" - }); - - expect(createEntryMock).toHaveBeenCalledTimes(1); - expect(createEntryMock).toHaveBeenCalledWith(schedulerModel, { - id: createScheduleRecordId(`target-id#0002`), - scheduledOn: scheduleOn.toISOString(), - scheduledBy: getIdentity(), - targetId: "target-id#0002", - targetModelId: "targetModel", - title: "Test Entry", - type: ScheduleType.unpublish - }); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts deleted file mode 100644 index 857e51e0433..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduleRecordId.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createScheduleRecordId, - createScheduleRecordIdWithVersion -} from "~/scheduler/createScheduleRecordId.js"; -import { SCHEDULE_ID_PREFIX } from "~/constants.js"; - -describe("createScheduleRecordId", () => { - it("should create a valid schedule record ID with version", () => { - const result = createScheduleRecordIdWithVersion("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - }); - - it("should create a valid schedule record ID from already created record ID with version", () => { - const result = createScheduleRecordIdWithVersion("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - - const rerunResult = createScheduleRecordIdWithVersion(result); - - expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001#0001`); - }); - - it("should create a valid schedule record ID without version", () => { - const result = createScheduleRecordId("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - }); - - it("should create a valid schedule record ID from already created record ID without version", () => { - const result = createScheduleRecordId("target-id#0001"); - - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - - const rerunResult = createScheduleRecordId(result); - - expect(rerunResult).toEqual(`${SCHEDULE_ID_PREFIX}target-id-0001`); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduler.test.ts b/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduler.test.ts deleted file mode 100644 index e3a6965e821..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/scheduler/createScheduler.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createScheduler } from "~/scheduler/createScheduler.js"; -import { createMockService } from "~tests/mocks/service.js"; -import { createMockSchedulerModel } from "~tests/mocks/schedulerModel.js"; -import { createMockTargetModel } from "~tests/mocks/targetModel.js"; -import { createMockCms } from "~tests/mocks/cms.js"; -import { createMockSecurity } from "~tests/mocks/security.js"; -import { Scheduler } from "~/scheduler/Scheduler.js"; - -describe("createScheduler", () => { - const service = createMockService(); - const schedulerModel = createMockSchedulerModel(); - const targetModel = createMockTargetModel(); - const cms = createMockCms(); - const security = createMockSecurity(); - - it("should create a scheduler", async () => { - const result = await createScheduler({ - service, - cms, - security, - schedulerModel - }); - expect(result).toBeInstanceOf(Function); - - const scheduler = result(targetModel); - expect(scheduler).toHaveProperty("fetcher"); - expect(scheduler).toHaveProperty("executor"); - expect(scheduler).toBeInstanceOf(Scheduler); - }); -}); diff --git a/packages/api-headless-cms-scheduler/__tests__/service/SchedulerService.test.ts b/packages/api-headless-cms-scheduler/__tests__/service/SchedulerService.test.ts deleted file mode 100644 index 66312e35d9a..00000000000 --- a/packages/api-headless-cms-scheduler/__tests__/service/SchedulerService.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { SchedulerService } from "~/service/SchedulerService.js"; -import type { - ISchedulerServiceCreateInput, - ISchedulerServiceUpdateInput -} from "~/service/types.js"; -import { WebinyError } from "@webiny/error"; -import { mockClient } from "aws-sdk-client-mock"; -import { - CreateScheduleCommand, - DeleteScheduleCommand, - GetScheduleCommand, - SchedulerClient, - UpdateScheduleCommand -} from "@webiny/aws-sdk/client-scheduler/index.js"; -import { describe, expect, it, vi } from "vitest"; - -describe("SchedulerService", () => { - const lambdaArn = "arn:aws:lambda:us-east-1:123456789012:function:test"; - const roleArn = "arn:aws:iam::123456789012:role/test-role"; - const config = { - lambdaArn, - roleArn - }; - - it("creates a schedule successfully", async () => { - const client = mockClient(SchedulerClient); - client.on(CreateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 999 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const input: ISchedulerServiceCreateInput = { - id: "schedule-1", - scheduleOn: new Date(Date.now() + 1000000) - }; - - const result = await service.create(input); - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); - }); - - it("throws if creating a schedule in the past", async () => { - const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const input: ISchedulerServiceCreateInput = { - id: "schedule-1", - scheduleOn: new Date(Date.now() - 100000) - }; - - try { - const result = await service.create(input); - expect(result).toEqual("SHOULD NOT REACH HERE"); - } catch (ex) { - expect(ex).toBeInstanceOf(WebinyError); - expect(ex.message).toContain( - `Cannot create a schedule for "schedule-1" with date in the past:` - ); - } - }); - - it("updates a schedule successfully", async () => { - const client = mockClient(SchedulerClient); - client.on(UpdateScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 999 - } - }); - client.on(GetScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const input: ISchedulerServiceUpdateInput = { - id: "schedule-1", - scheduleOn: new Date(Date.now() + 1000000) - }; - - const result = await service.update(input); - - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); - }); - - it("throws if updating a schedule in the past", async () => { - const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const input: ISchedulerServiceUpdateInput = { - id: "schedule-1", - scheduleOn: new Date(Date.now()) - }; - - try { - const result = await service.update(input); - expect(result).toEqual("SHOULD NOT REACH HERE"); - } catch (ex) { - expect(ex).toBeInstanceOf(WebinyError); - expect(ex.message).toContain( - `Cannot update an existing schedule for "schedule-1" with date in the past:` - ); - } - }); - - it("deletes a schedule successfully if it exists", async () => { - const client = mockClient(SchedulerClient); - - client.on(DeleteScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 999 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config - }); - vi.spyOn(service, "exists").mockResolvedValue(true); - - const result = await service.delete("schedule-1"); - - expect(result).toEqual({ - $metadata: { - httpStatusCode: 999 - } - }); - }); - - it("does not delete a schedule if it does not exist", async () => { - const client = mockClient(SchedulerClient); - const service = new SchedulerService({ - getClient: () => client, - config - }); - vi.spyOn(service, "exists").mockResolvedValue(false); - - try { - const result = await service.delete("schedule-1"); - expect(result).toEqual("SHOULD NOT REACH HERE"); - } catch (ex) { - expect(ex).toBeInstanceOf(WebinyError); - expect(ex.message).toContain( - `Cannot delete schedule "schedule-1" because it does not exist.` - ); - } - }); - - it("exists returns true if schedule is found", async () => { - const client = mockClient(SchedulerClient); - client.on(GetScheduleCommand).resolves({ - $metadata: { - httpStatusCode: 200 - } - }); - - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const result = await service.exists("schedule-1"); - - expect(result).toEqual(true); - }); - - it("exists returns false if ResourceNotFoundException is thrown", async () => { - const client = mockClient(SchedulerClient); - client.on(GetScheduleCommand).callsFake(async () => { - const error = new Error("Resource not found."); - error.name = "ResourceNotFoundException"; - throw error; - }); - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const result = await service.exists("schedule-1"); - expect(result).toBe(false); - }); - - it("throws on unknown error in exists", async () => { - const client = mockClient(SchedulerClient); - client.on(GetScheduleCommand).callsFake(async () => { - throw new Error("Unknown error."); - }); - const service = new SchedulerService({ - getClient: () => client, - config - }); - - const result = await service.exists("schedule-1"); - - expect(result).toEqual(false); - }); -}); diff --git a/packages/api-headless-cms-scheduler/package.json b/packages/api-headless-cms-scheduler/package.json index 16d736904e2..566185b7651 100644 --- a/packages/api-headless-cms-scheduler/package.json +++ b/packages/api-headless-cms-scheduler/package.json @@ -16,8 +16,7 @@ "dependencies": { "@webiny/api": "0.0.0", "@webiny/api-headless-cms": "0.0.0", - "@webiny/aws-sdk": "0.0.0", - "@webiny/error": "0.0.0", + "@webiny/api-scheduler": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/utils": "0.0.0", @@ -25,6 +24,7 @@ }, "devDependencies": { "@webiny/api-core": "0.0.0", + "@webiny/aws-sdk": "0.0.0", "@webiny/build-tools": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", diff --git a/packages/api-headless-cms-scheduler/src/constants.ts b/packages/api-headless-cms-scheduler/src/constants.ts deleted file mode 100644 index 7b50ef469e1..00000000000 --- a/packages/api-headless-cms-scheduler/src/constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const SCHEDULE_MODEL_ID = "webinyCmsSchedule"; -export const SCHEDULE_ID_PREFIX = "wby-schedule-"; -/** - * Minimum number of seconds in the future that a schedule can be set. - * Everything else will result in immediately running the action. - */ -export const SCHEDULE_MIN_FUTURE_SECONDS = 65; -/** - * - */ -export const SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER = "WebinyScheduledCmsAction"; diff --git a/packages/api-headless-cms-scheduler/src/context.ts b/packages/api-headless-cms-scheduler/src/context.ts index 075ef6aa299..8cb3c6c25b9 100644 --- a/packages/api-headless-cms-scheduler/src/context.ts +++ b/packages/api-headless-cms-scheduler/src/context.ts @@ -1,83 +1,17 @@ -import { ContextPlugin } from "@webiny/api"; -import type { - SchedulerClient, - SchedulerClientConfig -} from "@webiny/aws-sdk/client-scheduler/index.js"; -import { createSchedulerService } from "~/service/SchedulerService.js"; -import { getManifest } from "~/manifest.js"; -import { convertException } from "@webiny/utils"; -import { createScheduler } from "./scheduler/createScheduler.js"; -import type { CmsContext, CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { SCHEDULE_MODEL_ID } from "./constants.js"; -import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; -import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import { attachLifecycleHooks } from "~/hooks/index.js"; -import { SchedulerFactory } from "~/features/Scheduler/index.js"; - -export interface ICreateHeadlessCmsSchedulerContextParams { - getClient(config?: SchedulerClientConfig): Pick; -} - -export const createHeadlessCmsScheduleContext = ( - params: ICreateHeadlessCmsSchedulerContextParams -) => { - return new ContextPlugin(async context => { - /** - * If the Headless CMS is not ready, it means the system is not fully installed yet. - * We do not want to continue because it would break anyway. - */ - const ready = await isHeadlessCmsReady(context); - if (!ready) { - return; - } - const manifest = await getManifest({ - client: context.db.driver.getClient() as DynamoDBDocument - }); - if (manifest.error) { - console.error(manifest.error.message); - console.log(convertException(manifest.error, ["message"])); - console.info("Scheduler not attached."); - return; - } +// import { attachLifecycleHooks } from "~/hooks/index.js"; - const service = createSchedulerService({ - getClient: params.getClient, - config: { - lambdaArn: manifest.data.lambdaArn, - roleArn: manifest.data.roleArn - } - }); - - let schedulerModel: CmsModel; - try { - schedulerModel = await context.cms.getModel(SCHEDULE_MODEL_ID); - } catch (ex) { - if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) { - console.error(`Schedule model "${SCHEDULE_MODEL_ID}" not found.`); - return; - } - console.error(`Error while fetching schedule model "${SCHEDULE_MODEL_ID}".`); - console.log(convertException(ex)); - return; - } - - attachLifecycleHooks({ - cms: context.cms - }); +import { ContextPlugin } from "@webiny/api"; +import { ScheduleEntryActionFeature } from "~/features/ScheduleEntryAction/feature.js"; +import { CancelScheduledEntryActionFeature } from "~/features/CancelScheduledEntryAction/feature.js"; - const schedulerFactory = await createScheduler({ - cms: context.cms, - security: context.security, - service, - schedulerModel - }); +export const createHeadlessCmsScheduleContext = () => { + return new ContextPlugin(async context => { + ScheduleEntryActionFeature.register(context.container); + CancelScheduledEntryActionFeature.register(context.container); - // Register an adapter - context.container.registerInstance(SchedulerFactory, { - useModel(model) { - return schedulerFactory(model); - } - }); + // TODO: refactor to event handlers + // attachLifecycleHooks({ + // cms: context.cms + // }); }); }; diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/CancelScheduledEntryActionUseCase.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/CancelScheduledEntryActionUseCase.ts new file mode 100644 index 00000000000..e26559d1fe6 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/CancelScheduledEntryActionUseCase.ts @@ -0,0 +1,43 @@ +import { Result } from "@webiny/feature/api"; +import { ListScheduledActionsUseCase } from "@webiny/api-scheduler/features/ListScheduledActions"; +import { CancelScheduledActionUseCase } from "@webiny/api-scheduler/features/CancelScheduledAction"; +import { CancelScheduledEntryActionUseCase as UseCaseAbstraction } from "./abstractions.js"; + +/** + * Cancels a scheduled CMS entry action + */ +class CancelScheduledEntryActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private listScheduledActions: ListScheduledActionsUseCase.Interface, + private cancelScheduledAction: CancelScheduledActionUseCase.Interface + ) {} + + async execute( + input: UseCaseAbstraction.Input + ): Promise> { + const namespace = `Cms/Entry/${input.modelId}`; + + const actionsResult = await this.listScheduledActions.execute({ + where: { + namespace, + targetId: input.targetId + } + }); + + const actions = actionsResult.value.items; + + for (const action of actions) { + const cancelRes = await this.cancelScheduledAction.execute(action.id); + if (cancelRes.isFail()) { + return Result.fail(cancelRes.error); + } + } + + return Result.ok(); + } +} + +export const CancelScheduledEntryActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: CancelScheduledEntryActionUseCaseImpl, + dependencies: [ListScheduledActionsUseCase, CancelScheduledActionUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/abstractions.ts new file mode 100644 index 00000000000..d68abdf5971 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/abstractions.ts @@ -0,0 +1,42 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { + ScheduledActionNotFoundError, + ScheduledActionPersistenceError, + SchedulerServiceError +} from "@webiny/api-scheduler/domain/errors.js"; + +/** + * CancelScheduledEntryActionUseCase - Cancel a scheduled CMS entry action + * + * This is a convenience use case for canceling scheduled CMS entry actions. + */ + +export interface ICancelScheduledEntryActionInput { + modelId: string; + targetId: string; +} + +export interface ICancelScheduledEntryActionErrors { + notFound: ScheduledActionNotFoundError; + persistence: ScheduledActionPersistenceError; + schedulerService: SchedulerServiceError; +} + +type CancelScheduledEntryActionError = + ICancelScheduledEntryActionErrors[keyof ICancelScheduledEntryActionErrors]; + +export interface ICancelScheduledEntryActionUseCase { + execute( + input: ICancelScheduledEntryActionInput + ): Promise>; +} + +export const CancelScheduledEntryActionUseCase = + createAbstraction("CancelScheduledEntryActionUseCase"); + +export namespace CancelScheduledEntryActionUseCase { + export type Interface = ICancelScheduledEntryActionUseCase; + export type Input = ICancelScheduledEntryActionInput; + export type Error = CancelScheduledEntryActionError; +} diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/feature.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/feature.ts new file mode 100644 index 00000000000..006f6f142db --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/feature.ts @@ -0,0 +1,14 @@ +import { createFeature } from "@webiny/feature/api"; +import { CancelScheduledEntryActionUseCase } from "./CancelScheduledEntryActionUseCase.js"; + +/** + * CancelScheduledEntryAction Feature + * + * Provides the ability to cancel scheduled CMS entry actions (publish/unpublish). + */ +export const CancelScheduledEntryActionFeature = createFeature({ + name: "CancelScheduledEntryAction", + register(container) { + container.register(CancelScheduledEntryActionUseCase); + } +}); diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/index.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledEntryAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts deleted file mode 100644 index c4cbde617bb..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/ProcessRecordsUseCase.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Result } from "@webiny/feature/api"; -import { WebinyError } from "@webiny/error"; -import { SCHEDULE_MODEL_ID, SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { ProcessRecordsUseCase as UseCaseAbstraction } from "./abstractions.js"; -import { RecordAction } from "./abstractions.js"; -import { createIdentifier } from "@webiny/utils/createIdentifier.js"; -import { - AuthenticatedIdentity, - IdentityContext -} from "@webiny/api-core/features/security/IdentityContext/index.js"; -import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; -import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; -import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/index.js"; -import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/index.js"; -import { CmsModel } from "@webiny/api-headless-cms/types/model.js"; -import { SchedulerFactory } from "~/features/Scheduler/index.js"; - -/** - * RecordProcessorUseCase - Processes scheduled CMS action events - * - * Responsibilities: - * - Fetch the schedule entry from storage - * - Set identity to the user who scheduled the action - * - Find the appropriate action handler - * - Execute the action - * - Clean up schedule entry on success or update with error on failure - */ -class ProcessRecordsUseCaseImpl implements UseCaseAbstraction.Interface { - constructor( - private schedulerFactory: SchedulerFactory.Interface, - private actions: RecordAction.Interface[], - private identityContext: IdentityContext.Interface, - private getModel: GetModelUseCase.Interface, - private getEntryById: GetEntryByIdUseCase.Interface, - private updateEntry: UpdateEntryUseCase.Interface, - private deleteEntry: DeleteEntryUseCase.Interface - ) {} - - public async execute(payload: UseCaseAbstraction.Params): Promise> { - const values = payload[SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]; - - const model = await this.getModelDefinition(SCHEDULE_MODEL_ID); - - const scheduleEntryId = createIdentifier({ - id: values.id, - version: 1 - }); - - /** - * Fetch the schedule entry so we know the model it is targeting. - */ - const scheduleEntryResult = await this.identityContext.withoutAuthorization(() => { - return this.getEntryById.execute(model, scheduleEntryId); - }); - - if (scheduleEntryResult.isFail()) { - return Result.fail(scheduleEntryResult.error); - } - - const scheduleEntry = scheduleEntryResult.value; - - /** - * We want to mock the identity of the user that scheduled this record. - */ - this.identityContext.setIdentity( - new AuthenticatedIdentity({ - id: scheduleEntry.createdBy.id, - type: scheduleEntry.createdBy.type, - displayName: scheduleEntry.createdBy.displayName ?? "" - }) - ); - - const targetModel = await this.getModelDefinition(scheduleEntry.values.targetModelId); - - /** - * We want a formatted schedule record to be used later. - */ - const scheduler = this.schedulerFactory.useModel(targetModel); - const scheduleRecord = await scheduler.getScheduled(scheduleEntryId); - - /** - * Should not happen as we fetched it a few lines up, just in different format. - */ - if (!scheduleRecord) { - return Result.fail( - new WebinyError( - `No schedule record found for ID: ${scheduleEntryId}`, - "SCHEDULE_RECORD_NOT_FOUND", - values - ) - ); - } - - const action = this.actions.find(action => action.canHandle(scheduleRecord)); - if (!action) { - await this.updateEntry.execute(model, scheduleEntryId, { - error: `No action found for schedule record ID.` - }); - - return Result.fail( - new WebinyError( - `No action found for schedule record ID: ${scheduleEntryId}`, - "NO_ACTION_FOUND", - scheduleRecord - ) - ); - } - - try { - await action.handle(scheduleRecord); - } catch (ex) { - console.error(`Error while handling schedule record ID: ${scheduleEntryId}`); - await this.updateEntry.execute(model, scheduleEntryId, { - error: ex.message - }); - return Result.fail(ex); - } - - /** - * Everything is ok. Delete the schedule record. - */ - try { - await this.deleteEntry.execute(model, scheduleEntryId, { - force: true, - permanently: true - }); - } catch { - // Does not matter if it fails. - } - - return Result.ok(); - } - - private async getModelDefinition(modelId: string): Promise { - const modelResult = await this.identityContext.withoutAuthorization(() => { - return this.getModel.execute(modelId); - }); - - if (modelResult.isFail()) { - throw modelResult.error; - } - - return modelResult.value; - } -} - -export const ProcessRecordsUseCase = UseCaseAbstraction.createImplementation({ - implementation: ProcessRecordsUseCaseImpl, - dependencies: [ - SchedulerFactory, - [RecordAction, { multiple: true }], - IdentityContext, - GetModelUseCase, - GetEntryByIdUseCase, - UpdateEntryUseCase, - DeleteEntryUseCase - ] -}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts deleted file mode 100644 index 23778a32f83..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/abstractions.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { createAbstraction, Result } from "@webiny/feature/api"; -import type { IScheduleRecord } from "~/scheduler/types.js"; -import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; - -/** - * RecordAction Abstraction - * - * Handles a specific type of scheduled action (publish, unpublish, etc.) - */ -export interface IRecordAction { - /** - * Determines if this action can handle the given schedule record - */ - canHandle(record: IScheduleRecord): boolean; - - /** - * Processes the schedule record - */ - handle(record: IScheduleRecord): Promise; -} - -export const RecordAction = createAbstraction("RecordAction"); - -export namespace RecordAction { - export type Interface = IRecordAction; -} - -/** - * ProcessRecords Abstraction - * - * Processes scheduled CMS action events by delegating to appropriate actions - */ -export interface IWebinyScheduledCmsActionEventValues { - id: string; // id of the schedule record - scheduleOn: string; -} - -export interface IWebinyScheduledCmsActionEvent { - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: IWebinyScheduledCmsActionEventValues; -} - - -export interface IProcessRecords { - /** - * Processes a scheduled CMS action event - */ - execute(payload: IWebinyScheduledCmsActionEvent): Promise>; -} - -export const ProcessRecordsUseCase = createAbstraction("ProcessRecordsUseCase"); - -export namespace ProcessRecordsUseCase { - export type Interface = IProcessRecords; - export type Params = IWebinyScheduledCmsActionEvent; -} diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts deleted file mode 100644 index 02244be957d..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/PublishRecordAction.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { RecordAction as RecordActionAbstraction } from "../abstractions.js"; -import type { IScheduleRecord } from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; -import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds/index.js"; -import { PublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/PublishEntry/index.js"; -import { RepublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/RepublishEntry/abstractions.js"; -import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry/abstractions.js"; - -/** - * PublishRecordAction - Handles publish scheduled actions - * - * Responsibilities: - * - Check if record type is publish - * - Fetch target entry and published entry - * - Handle three scenarios: - * 1. Entry not published -> publish it - * 2. Entry already published (same revision) -> republish it - * 3. Entry has different published revision -> unpublish old, publish new - */ -class PublishRecordActionImpl implements RecordActionAbstraction.Interface { - constructor( - private getEntryById: GetEntryByIdUseCase.Interface, - private getPublishedEntriesByIds: GetPublishedEntriesByIdsUseCase.Interface, - private publishEntry: PublishEntryUseCase.Interface, - private republishEntry: RepublishEntryUseCase.Interface, - private unpublishEntry: UnpublishEntryUseCase.Interface - ) {} - - public canHandle(record: Pick): boolean { - return record.type === ScheduleType.publish; - } - - public async handle(record: Pick): Promise { - const { targetId, model } = record; - - const targetEntryResult = await this.getEntryById.execute(model, targetId); - if (targetEntryResult.isFail()) { - throw targetEntryResult.error; - } - - const targetEntry = targetEntryResult.value; - - const publishedTargetEntryResult = await this.getPublishedEntriesByIds.execute(model, [ - targetEntry.id - ]); - if (publishedTargetEntryResult.isFail()) { - throw publishedTargetEntryResult.error; - } - - const [publishedTargetEntry] = publishedTargetEntryResult.value; - - /** - * There are a few scenarios we must handle: - * 1. target entry is not published - * 2. target entry is already published, same revision published - * 3. target entry has a published revision, which is different that the target - */ - - /** - * 1. Has no published revision, so we can publish it. - */ - if (!publishedTargetEntry) { - try { - await this.publishEntry.execute(model, targetEntry.id); - return; - } catch (error) { - console.error(`Failed to publish entry "${targetId}":`, error); - throw error; - } - } else if (publishedTargetEntry.id === targetEntry.id) { - /** - * 2. Target entry is already published. - */ - /** - * Already published, nothing to do. - */ - await this.republishEntry.execute(model, targetEntry.id); - return; - } - /** - * 3. Target entry has a published revision, which is different from the target. - */ - await this.unpublishEntry.execute(model, publishedTargetEntry.id); - await this.publishEntry.execute(model, targetEntry.id); - } -} - -export const PublishRecordAction = RecordActionAbstraction.createImplementation({ - implementation: PublishRecordActionImpl, - dependencies: [ - GetEntryByIdUseCase, - GetPublishedEntriesByIdsUseCase, - PublishEntryUseCase, - RepublishEntryUseCase, - UnpublishEntryUseCase - ] -}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts deleted file mode 100644 index 4f829895c7f..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/actions/UnpublishRecordAction.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { RecordAction as RecordActionAbstraction } from "../abstractions.js"; -import type { IScheduleRecord } from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; -import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds/index.js"; -import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry/index.js"; - -/** - * UnpublishRecordAction - Handles unpublish scheduled actions - * - * Responsibilities: - * - Check if record type is unpublish - * - Fetch target entry and published entry - * - Handle three scenarios: - * 1. Entry not published -> nothing to do - * 2. Entry published (same revision) -> unpublish it - * 3. Entry has different published revision -> unpublish the published one - */ -class UnpublishRecordActionImpl implements RecordActionAbstraction.Interface { - constructor( - private getEntryById: GetEntryByIdUseCase.Interface, - private getPublishedEntriesByIds: GetPublishedEntriesByIdsUseCase.Interface, - private unpublishEntry: UnpublishEntryUseCase.Interface - ) {} - - public canHandle(record: Pick): boolean { - return record.type === ScheduleType.unpublish; - } - - public async handle(record: Pick): Promise { - const { targetId, model } = record; - - /** - * We need to handle the following scenarios: - * 1. Target entry is not published, nothing to do. - * 2. Target entry is published, so we can unpublish it. - * 3. Target entry is published, but it's a different revision than the target. - */ - - const targetEntryResult = await this.getEntryById.execute(model, targetId); - if (targetEntryResult.isFail()) { - throw targetEntryResult.error; - } - - const targetEntry = targetEntryResult.value; - - const publishedTargetEntryResult = await this.getPublishedEntriesByIds.execute(model, [ - targetEntry.id - ]); - if (publishedTargetEntryResult.isFail()) { - throw publishedTargetEntryResult.error; - } - - const [publishedTargetEntry] = publishedTargetEntryResult.value; - - /** - * 1. Target entry is not published, nothing to do. - */ - if (!publishedTargetEntry) { - console.warn(`Entry "${targetId}" is not published, nothing to unpublish.`); - return; - } else if (publishedTargetEntry.id === targetId) { - /** - * 2. Target entry is published, so we can unpublish it. - */ - await this.unpublishEntry.execute(model, targetId); - return; - } - /** - * 3. Target entry is published, but it's a different revision than the target. - * TODO determine if we really want to unpublish an entry which does not match the target ID. - */ - await this.unpublishEntry.execute(model, publishedTargetEntry.id); - } -} - -export const UnpublishRecordAction = RecordActionAbstraction.createImplementation({ - implementation: UnpublishRecordActionImpl, - dependencies: [GetEntryByIdUseCase, GetPublishedEntriesByIdsUseCase, UnpublishEntryUseCase] -}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts deleted file mode 100644 index 08f7cb999c4..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/feature.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createFeature } from "@webiny/feature/api"; -import { ProcessRecordsUseCase } from "./ProcessRecordsUseCase.js"; -import { PublishRecordAction } from "./actions/PublishRecordAction.js"; -import { UnpublishRecordAction } from "./actions/UnpublishRecordAction.js"; - -/** - * ProcessRecordsFeature Feature - * - * Provides functionality for processing scheduled CMS action events. - * Delegates to specific action handlers (publish, unpublish). - */ -export const ProcessRecordsFeature = createFeature({ - name: "ProcessRecordsFeature", - register(container) { - // Register use case - container.register(ProcessRecordsUseCase); - - // Register action handlers - container.register(PublishRecordAction); - container.register(UnpublishRecordAction); - } -}); diff --git a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts b/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts deleted file mode 100644 index 398cb46f28e..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/ProcessRecords/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ProcessRecordsUseCase, RecordAction } from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/ScheduleEntryActionUseCase.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/ScheduleEntryActionUseCase.ts new file mode 100644 index 00000000000..89cb6c0e65a --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/ScheduleEntryActionUseCase.ts @@ -0,0 +1,95 @@ +import { Result } from "@webiny/feature/api"; +import { ScheduleEntryActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { ScheduleActionUseCase } from "@webiny/api-scheduler"; +import { RunActionUseCase } from "@webiny/api-scheduler"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/index.js"; + +/** + * Schedules a CMS entry action (publish or unpublish) + * + * Flow: + * 1. If immediately=true, use RunAction for immediate execution + * 2. Otherwise, fetch entry to get title metadata + * 3. Use ScheduleAction with entry title in payload + */ +class ScheduleEntryActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private scheduleActionUseCase: ScheduleActionUseCase.Interface, + private runActionUseCase: RunActionUseCase.Interface, + private getModelUseCase: GetModelUseCase.Interface, + private getEntryByIdUseCase: GetEntryByIdUseCase.Interface + ) {} + + async execute( + input: UseCaseAbstraction.Input + ): Promise> { + const namespace = `Cms/Entry/${input.modelId}`; + + // Handle immediate execution + if (input.immediately) { + const result = await this.runActionUseCase.execute({ + namespace, + actionType: input.actionType, + targetId: input.targetId, + payload: { + modelId: input.modelId + } + }); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } + + // Validate scheduleOn is provided for future scheduling + if (!input.scheduleOn) { + throw new Error("scheduleOn is required when immediately is not true"); + } + + // Fetch the target model + const modelResult = await this.getModelUseCase.execute(input.modelId); + if (modelResult.isFail()) { + return Result.fail(modelResult.error as any); + } + + const model = modelResult.value; + + // Fetch entry to get title + const entryResult = await this.getEntryByIdUseCase.execute(model, input.targetId); + if (entryResult.isFail()) { + return Result.fail(entryResult.error as any); + } + + const entry = entryResult.value; + const title = entry.values[model.titleFieldId] || "Unknown entry title"; + + // Schedule with title metadata + const scheduleResult = await this.scheduleActionUseCase.execute({ + namespace, + actionType: input.actionType, + targetId: input.targetId, + title, + input: { + scheduleOn: input.scheduleOn + }, + payload: { + modelId: input.modelId + } + }); + + if (scheduleResult.isFail()) { + return Result.fail(scheduleResult.error); + } + + return Result.ok(scheduleResult.value); + } +} + +export const ScheduleEntryActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: ScheduleEntryActionUseCaseImpl, + dependencies: [ScheduleActionUseCase, RunActionUseCase, GetModelUseCase, GetEntryByIdUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts new file mode 100644 index 00000000000..f30e152a219 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts @@ -0,0 +1,54 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { + ScheduledActionPersistenceError, + InvalidScheduleDateError, + SchedulerServiceError +} from "@webiny/api-scheduler/domain/errors.js"; +import type { ModelNotFoundError } from "@webiny/api-headless-cms/domain/contentModel/errors.js"; +import type { EntryNotFoundError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +/** + * ScheduleEntryActionUseCase - Schedule a CMS entry action (publish/unpublish) + * + * This is a convenience use case for scheduling CMS entry actions. + * It handles: + * - Immediate execution (via RunAction) + * - Future scheduling (via ScheduleAction) + * - Fetching entry title for schedule metadata + */ + +export type ScheduleEntryActionType = "Publish" | "Unpublish"; + +export interface IScheduleEntryActionInput { + modelId: string; + targetId: string; + actionType: ScheduleEntryActionType; + immediately?: boolean; + scheduleOn?: Date; +} + +export interface IScheduleEntryActionErrors { + persistence: ScheduledActionPersistenceError; + invalidDate: InvalidScheduleDateError; + schedulerService: SchedulerServiceError; + modelNotFound: ModelNotFoundError; + entryNotFound: EntryNotFoundError; +} + +type ScheduleEntryActionError = IScheduleEntryActionErrors[keyof IScheduleEntryActionErrors]; + +export interface IScheduleEntryActionUseCase { + execute(input: IScheduleEntryActionInput): Promise>; +} + +export const ScheduleEntryActionUseCase = createAbstraction( + "ScheduleEntryActionUseCase" +); + +export namespace ScheduleEntryActionUseCase { + export type Interface = IScheduleEntryActionUseCase; + export type Input = IScheduleEntryActionInput; + export type Error = ScheduleEntryActionError; +} diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/PublishEntryActionHandler.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/PublishEntryActionHandler.ts new file mode 100644 index 00000000000..34c55661f79 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/PublishEntryActionHandler.ts @@ -0,0 +1,138 @@ +import { ScheduledActionHandler } from "@webiny/api-scheduler"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds"; +import { PublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/PublishEntry"; +import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry"; +import { RepublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/RepublishEntry"; + +/** + * Handler for publishing CMS entries + * + * Handles the "Publish" action for CMS entries with namespace pattern: Cms/Entry/{modelId} + * + * Publishing logic: + * 1. If entry is not published -> publish it + * 2. If the same revision is already published -> republish it + * 3. If a different revision is published -> unpublish old, publish new + */ +class PublishEntryActionHandlerImpl implements ScheduledActionHandler.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private getEntryByIdUseCase: GetEntryByIdUseCase.Interface, + private getPublishedEntriesByIdsUseCase: GetPublishedEntriesByIdsUseCase.Interface, + private publishEntryUseCase: PublishEntryUseCase.Interface, + private unpublishEntryUseCase: UnpublishEntryUseCase.Interface, + private republishEntryUseCase: RepublishEntryUseCase.Interface + ) {} + + canHandle(namespace: string, actionType: string): boolean { + return namespace.startsWith("Cms/Entry/") && actionType === "Publish"; + } + + async handle(action: IScheduledAction): Promise { + const { payload } = action; + + const modelId = payload.modelId as string; + + // Fetch the model + const modelResult = await this.getModelUseCase.execute(modelId); + if (modelResult.isFail()) { + console.error( + `Failed to get model "${modelId}" for scheduled publish action:`, + modelResult.error + ); + throw new Error(`Model not found: ${modelId}`); + } + + const model = modelResult.value; + + // Fetch the target entry + const targetEntryResult = await this.getEntryByIdUseCase.execute(model, action.targetId); + if (targetEntryResult.isFail()) { + console.error( + `Failed to get entry "${action.targetId}" for scheduled publish action:`, + targetEntryResult.error + ); + throw new Error(`Entry not found: ${action.targetId}`); + } + + const targetEntry = targetEntryResult.value; + + // Get published entries + const publishedEntriesResult = await this.getPublishedEntriesByIdsUseCase.execute(model, [ + targetEntry.id + ]); + + if (publishedEntriesResult.isFail()) { + console.error( + `Failed to get published entries for "${targetEntry.id}":`, + publishedEntriesResult.error + ); + throw new Error(`Failed to check published entries`); + } + + const [publishedTargetEntry] = publishedEntriesResult.value; + + /** + * Scenario 1: Entry has no published revision -> publish it + */ + if (!publishedTargetEntry) { + const publishResult = await this.publishEntryUseCase.execute(model, targetEntry.id); + if (publishResult.isFail()) { + console.error(`Failed to publish entry "${action.targetId}":`, publishResult.error); + throw new Error(`Failed to publish entry: ${action.targetId}`); + } + return; + } + + /** + * Scenario 2: Target entry is already published (same revision) -> republish it + */ + if (publishedTargetEntry.id === targetEntry.id) { + const republishResult = await this.republishEntryUseCase.execute(model, targetEntry.id); + if (republishResult.isFail()) { + console.error( + `Failed to republish entry "${action.targetId}":`, + republishResult.error + ); + throw new Error(`Failed to republish entry: ${action.targetId}`); + } + return; + } + + /** + * Scenario 3: A different revision is published -> unpublish old, publish new + */ + const unpublishResult = await this.unpublishEntryUseCase.execute( + model, + publishedTargetEntry.id + ); + if (unpublishResult.isFail()) { + console.error( + `Failed to unpublish old revision "${publishedTargetEntry.id}":`, + unpublishResult.error + ); + throw new Error(`Failed to unpublish old revision: ${publishedTargetEntry.id}`); + } + + const publishResult = await this.publishEntryUseCase.execute(model, targetEntry.id); + if (publishResult.isFail()) { + console.error(`Failed to publish entry "${action.targetId}":`, publishResult.error); + throw new Error(`Failed to publish entry: ${action.targetId}`); + } + } +} + +export const PublishEntryActionHandler = ScheduledActionHandler.createImplementation({ + implementation: PublishEntryActionHandlerImpl, + dependencies: [ + GetModelUseCase, + GetEntryByIdUseCase, + GetPublishedEntriesByIdsUseCase, + PublishEntryUseCase, + UnpublishEntryUseCase, + RepublishEntryUseCase + ] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/UnpublishEntryActionHandler.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/UnpublishEntryActionHandler.ts new file mode 100644 index 00000000000..002fae4a2a0 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/actionHandlers/UnpublishEntryActionHandler.ts @@ -0,0 +1,126 @@ +import { ScheduledActionHandler } from "@webiny/api-scheduler"; +import type { IScheduledAction } from "@webiny/api-scheduler"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds"; +import { UnpublishEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry"; + +/** + * Handler for unpublishing CMS entries + * + * Handles the "Unpublish" action for CMS entries with namespace pattern: Cms/Entry/{modelId} + * + * Unpublishing logic: + * 1. If entry is not published -> nothing to do (warn) + * 2. If target entry is published (same revision) -> unpublish it + * 3. If a different revision is published -> unpublish it anyway + */ +class UnpublishEntryActionHandlerImpl implements ScheduledActionHandler.Interface { + constructor( + private getModelUseCase: GetModelUseCase.Interface, + private getEntryByIdUseCase: GetEntryByIdUseCase.Interface, + private getPublishedEntriesByIdsUseCase: GetPublishedEntriesByIdsUseCase.Interface, + private unpublishEntryUseCase: UnpublishEntryUseCase.Interface + ) {} + + canHandle(namespace: string, actionType: string): boolean { + return namespace.startsWith("Cms/Entry/") && actionType === "Unpublish"; + } + + async handle(action: IScheduledAction): Promise { + const { payload } = action; + + const modelId = payload.modelId as string; + + // Fetch the model + const modelResult = await this.getModelUseCase.execute(modelId); + if (modelResult.isFail()) { + console.error( + `Failed to get model "${modelId}" for scheduled unpublish action:`, + modelResult.error + ); + throw new Error(`Model not found: ${modelId}`); + } + + const model = modelResult.value; + + // Fetch the target entry + const targetEntryResult = await this.getEntryByIdUseCase.execute(model, action.targetId); + if (targetEntryResult.isFail()) { + console.error( + `Failed to get entry "${action.targetId}" for scheduled unpublish action:`, + targetEntryResult.error + ); + throw new Error(`Entry not found: ${action.targetId}`); + } + + const targetEntry = targetEntryResult.value; + + // Get published entries + const publishedEntriesResult = await this.getPublishedEntriesByIdsUseCase.execute(model, [ + targetEntry.id + ]); + + if (publishedEntriesResult.isFail()) { + console.error( + `Failed to get published entries for "${targetEntry.id}":`, + publishedEntriesResult.error + ); + throw new Error(`Failed to check published entries`); + } + + const [publishedTargetEntry] = publishedEntriesResult.value; + + /** + * Scenario 1: Entry is not published -> nothing to do + */ + if (!publishedTargetEntry) { + console.warn(`Entry "${action.targetId}" is not published, nothing to unpublish.`); + return; + } + + /** + * Scenario 2: Target entry is published (same revision) -> unpublish it + */ + if (publishedTargetEntry.id === action.targetId) { + const unpublishResult = await this.unpublishEntryUseCase.execute( + model, + action.targetId + ); + if (unpublishResult.isFail()) { + console.error( + `Failed to unpublish entry "${action.targetId}":`, + unpublishResult.error + ); + throw new Error(`Failed to unpublish entry: ${action.targetId}`); + } + return; + } + + /** + * Scenario 3: A different revision is published -> unpublish it anyway + * TODO: Determine if we really want to unpublish an entry which does not match the target ID + */ + const unpublishResult = await this.unpublishEntryUseCase.execute( + model, + publishedTargetEntry.id + ); + if (unpublishResult.isFail()) { + console.error( + `Failed to unpublish published revision "${publishedTargetEntry.id}":`, + unpublishResult.error + ); + throw new Error(`Failed to unpublish entry: ${publishedTargetEntry.id}`); + } + } +} + +export const UnpublishEntryActionHandler = ScheduledActionHandler.createImplementation({ + implementation: UnpublishEntryActionHandlerImpl, + dependencies: [ + GetModelUseCase, + GetEntryByIdUseCase, + GetPublishedEntriesByIdsUseCase, + UnpublishEntryUseCase + ] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/feature.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/feature.ts new file mode 100644 index 00000000000..ccaa28ba304 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/feature.ts @@ -0,0 +1,19 @@ +import { createFeature } from "@webiny/feature/api"; +import { ScheduleEntryActionUseCase } from "./ScheduleEntryActionUseCase.js"; +import { PublishEntryActionHandler } from "./actionHandlers/PublishEntryActionHandler.js"; +import { UnpublishEntryActionHandler } from "./actionHandlers/UnpublishEntryActionHandler.js"; + +/** + * ScheduleEntryAction Feature + * + * Provides the ability to schedule CMS entry actions (publish/unpublish). + * Handles both immediate execution and future scheduling. + */ +export const ScheduleEntryActionFeature = createFeature({ + name: "ScheduleEntryAction", + register(container) { + container.register(ScheduleEntryActionUseCase); + container.register(PublishEntryActionHandler); + container.register(UnpublishEntryActionHandler); + } +}); diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/index.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts deleted file mode 100644 index 66388c49e4d..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/Scheduler/abstractions.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { createAbstraction } from "@webiny/feature/api"; -import type { IScheduler } from "~/scheduler/types.js"; - -export interface ISchedulerFactory { - useModel(model: CmsModel): IScheduler; -} - -export const SchedulerFactory = createAbstraction("SchedulerFactory"); - -export namespace SchedulerFactory { - export type Interface = ISchedulerFactory; -} diff --git a/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts b/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts deleted file mode 100644 index 5e9b17ca304..00000000000 --- a/packages/api-headless-cms-scheduler/src/features/Scheduler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SchedulerFactory } from "./abstractions.js"; diff --git a/packages/api-headless-cms-scheduler/src/graphql/ActionMapper.ts b/packages/api-headless-cms-scheduler/src/graphql/ActionMapper.ts new file mode 100644 index 00000000000..04dbd949a44 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/graphql/ActionMapper.ts @@ -0,0 +1,18 @@ +import type { IScheduledAction } from "@webiny/api-scheduler"; + +export class ActionMapper { + static fromScheduledAction(modelId: string, action: IScheduledAction) { + return { + id: action.id, + targetId: action.targetId, + model: { + modelId + }, + scheduledBy: action.scheduledBy, + publishOn: action.actionType === "Publish" ? action.scheduledOn : null, + unpublishOn: action.actionType === "Unpublish" ? action.scheduledOn : null, + type: action.actionType.toLowerCase(), + title: action.title + }; + } +} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/dates.ts b/packages/api-headless-cms-scheduler/src/graphql/dates.ts similarity index 83% rename from packages/api-headless-cms-scheduler/src/scheduler/dates.ts rename to packages/api-headless-cms-scheduler/src/graphql/dates.ts index 16b29e5433d..ea9f4e34e15 100644 --- a/packages/api-headless-cms-scheduler/src/scheduler/dates.ts +++ b/packages/api-headless-cms-scheduler/src/graphql/dates.ts @@ -1,4 +1,5 @@ -import type { DateISOString } from "~/scheduler/types.js"; +export type DateISOString = + `${number}-${number}-${number}T${number}:${number}:${number}.${number}Z`; /** * We can safely cast the result of `toISOString()` to `DateISOString` type, diff --git a/packages/api-headless-cms-scheduler/src/graphql/index.ts b/packages/api-headless-cms-scheduler/src/graphql/index.ts index 553a8332421..3ff43360b31 100644 --- a/packages/api-headless-cms-scheduler/src/graphql/index.ts +++ b/packages/api-headless-cms-scheduler/src/graphql/index.ts @@ -9,7 +9,15 @@ import { updateScheduleSchema } from "~/graphql/schema.js"; import { createZodError } from "@webiny/utils"; -import { SchedulerFactory } from "~/features/Scheduler/index.js"; +import { ListScheduledActionsUseCase } from "@webiny/api-scheduler/features/ListScheduledActions"; +import { ScheduleEntryActionUseCase } from "~/features/ScheduleEntryAction/index.js"; +import { CancelScheduledEntryActionUseCase } from "~/features/CancelScheduledEntryAction/index.js"; +import { ActionMapper } from "~/graphql/ActionMapper.js"; + +const typeMap = { + publish: "Publish", + unpublish: "Unpublish" +} as const; const resolve = async (cb: () => Promise) => { try { @@ -38,17 +46,6 @@ const resolveList = async (cb: () => Promise) => { export const createSchedulerGraphQL = () => { return new CmsGraphQLSchemaPlugin({ - /** - * Make sure SchedulerFactory is available. No point in adding GraphQL if scheduler is unavailable for any reason. - */ - isApplicable: context => { - try { - context.container.resolve(SchedulerFactory); - return true; - } catch { - return false; - } - }, typeDefs: /* GraphQL */ ` enum CmsScheduleRecordType { publish @@ -107,7 +104,6 @@ export const createSchedulerGraphQL = () => { targetId: ID title_contains: String title_not_contains: String - targetEntryId: ID type: CmsScheduleRecordType scheduledBy: ID scheduledOn: DateTime @@ -156,11 +152,22 @@ export const createSchedulerGraphQL = () => { throw createZodError(validated.error); } - const schedulerFactory = context.container.resolve(SchedulerFactory); - const model = await context.cms.getModel(validated.data.modelId); - const scheduler = schedulerFactory.useModel(model); + const listActions = context.container.resolve(ListScheduledActionsUseCase); + + const actions = await listActions.execute({ + where: { namespace: `Cms/Entry/${args.modelId}`, targetId: args.id } + }); + + if (actions.isFail()) { + return new ErrorResponse({ + code: actions.error.code, + message: actions.error.message + }); + } + + const action = actions.value.items[0]; - return scheduler.getScheduled(validated.data.id); + return new Response(ActionMapper.fromScheduledAction(args.modelId, action)); }); }, async listCmsSchedules(_, args, context) { @@ -169,16 +176,31 @@ export const createSchedulerGraphQL = () => { if (validated.error) { throw createZodError(validated.error); } - const schedulerFactory = context.container.resolve(SchedulerFactory); - const model = await context.cms.getModel(validated.data.modelId); - const scheduler = schedulerFactory.useModel(model); - return scheduler.listScheduled({ - where: validated.data.where || {}, + const listActions = context.container.resolve(ListScheduledActionsUseCase); + + const { type, targetId, ...where } = validated.data.where ?? {}; + + if (type) { + // @ts-expect-error + where["actionType"] = typeMap[type]; + } + + const actions = await listActions.execute({ + where: { ...where, namespace: `Cms/Entry/${args.modelId}` }, sort: validated.data.sort, limit: validated.data.limit, after: validated.data.after }); + + if (actions.isFail()) { + throw actions.error; + } + + return { + data: actions.value.items, + meta: actions.value.meta + }; }); } }, @@ -190,11 +212,22 @@ export const createSchedulerGraphQL = () => { throw createZodError(validated.error); } - const schedulerFactory = context.container.resolve(SchedulerFactory); - const model = await context.cms.getModel(validated.data.modelId); - const scheduler = schedulerFactory.useModel(model); + const data = validated.data; + + const scheduleEntry = context.container.resolve(ScheduleEntryActionUseCase); + const result = await scheduleEntry.execute({ + modelId: data.modelId, + targetId: data.id, + scheduleOn: data.input.scheduleOn, + immediately: data.input.immediately, + actionType: typeMap[data.input.type] + }); - return await scheduler.schedule(validated.data.id, validated.data.input); + if (result.isFail()) { + throw result.error; + } + + return ActionMapper.fromScheduledAction(data.modelId, result.value); }); }, async updateCmsSchedule(_, args, context) { @@ -204,11 +237,22 @@ export const createSchedulerGraphQL = () => { throw createZodError(validated.error); } - const schedulerFactory = context.container.resolve(SchedulerFactory); - const model = await context.cms.getModel(validated.data.modelId); - const scheduler = schedulerFactory.useModel(model); + const data = validated.data; + + const scheduleEntry = context.container.resolve(ScheduleEntryActionUseCase); + const result = await scheduleEntry.execute({ + modelId: data.modelId, + targetId: data.id, + scheduleOn: data.input.scheduleOn, + immediately: data.input.immediately, + actionType: typeMap[data.input.type] + }); - return scheduler.schedule(validated.data.id, validated.data.input); + if (result.isFail()) { + throw result.error; + } + + return ActionMapper.fromScheduledAction(data.modelId, result.value); }); }, async cancelCmsSchedule(_, args, context) { @@ -218,11 +262,19 @@ export const createSchedulerGraphQL = () => { throw createZodError(validated.error); } - const schedulerFactory = context.container.resolve(SchedulerFactory); - const model = await context.cms.getModel(validated.data.modelId); - const scheduler = schedulerFactory.useModel(model); + const cancelEntryAction = context.container.resolve( + CancelScheduledEntryActionUseCase + ); + + const res = await cancelEntryAction.execute({ + modelId: validated.data.modelId, + targetId: validated.data.id + }); + + if (res.isFail()) { + throw res.error; + } - await scheduler.cancel(validated.data.id); return true; }); } diff --git a/packages/api-headless-cms-scheduler/src/graphql/schema.ts b/packages/api-headless-cms-scheduler/src/graphql/schema.ts index 4c9592f9614..9e15820a2a8 100644 --- a/packages/api-headless-cms-scheduler/src/graphql/schema.ts +++ b/packages/api-headless-cms-scheduler/src/graphql/schema.ts @@ -3,14 +3,18 @@ import type { CmsEntryListSortAsc, CmsEntryListSortDesc } from "@webiny/api-headless-cms/types/index.js"; -import { type DateOnType, ScheduleType } from "~/scheduler/types.js"; -import { dateToISOString } from "~/scheduler/dates.js"; +import { dateToISOString } from "./dates.js"; export const getScheduleSchema = zod.object({ modelId: zod.string(), id: zod.string() }); +export enum ScheduleType { + publish = "publish", + unpublish = "unpublish" +} + const publishAndUnpublishSchemaType = zod.nativeEnum(ScheduleType); export const listScheduleSchema = zod.object({ @@ -18,7 +22,7 @@ export const listScheduleSchema = zod.object({ where: zod .object({ targetId: zod.string().optional(), - targetEntryId: zod.string().optional(), + namespace: zod.string().optional(), title_contains: zod.string().optional(), title_not_contains: zod.string().optional(), type: publishAndUnpublishSchemaType.optional(), @@ -69,18 +73,10 @@ export const listScheduleSchema = zod.object({ after: zod.string().optional() }); -const dateOnSchema = zod - .date() - .optional() - .transform(value => { - return value instanceof Date ? value : undefined; - }); - const schedulerInputSchema = zod.discriminatedUnion("immediately", [ zod.object({ immediately: zod.literal(true), scheduleOn: zod.never().optional(), - dateOn: zod.date().optional(), type: publishAndUnpublishSchemaType }), zod.object({ @@ -90,7 +86,6 @@ const schedulerInputSchema = zod.discriminatedUnion("immediately", [ return new Date(value); }) ), - dateOn: dateOnSchema, type: publishAndUnpublishSchemaType }) ]); diff --git a/packages/api-headless-cms-scheduler/src/index.ts b/packages/api-headless-cms-scheduler/src/index.ts index dde3b890b32..bebfb15be9c 100644 --- a/packages/api-headless-cms-scheduler/src/index.ts +++ b/packages/api-headless-cms-scheduler/src/index.ts @@ -1,28 +1,11 @@ -import type { Plugin } from "@webiny/plugins/types.js"; -import type { ICreateHeadlessCmsSchedulerContextParams } from "~/context.js"; +import type { PluginCollection } from "@webiny/plugins/types.js"; import { createHeadlessCmsScheduleContext } from "~/context.js"; -import { createSchedulerModel } from "~/scheduler/model.js"; import { createSchedulerGraphQL } from "~/graphql/index.js"; -import { createScheduledCmsActionEventHandler } from "~/handler/index.js"; - -export type ICreateHeadlessCmsScheduleParams = ICreateHeadlessCmsSchedulerContextParams; /** * This will register both API and Handler plugins for the Headless CMS Scheduler. - * Handler plugin will handle the scheduled CMS action event - a lambda call from the EventBridge Scheduler. * API plugin will provide the GraphQL API and code for managing the scheduled CMS actions. */ -export const createHeadlessCmsScheduler = (params: ICreateHeadlessCmsScheduleParams): Plugin[] => { - return [ - /** - * Handler for the Scheduled CMS Action Event. - */ - createScheduledCmsActionEventHandler(), - /** - * API side of the scheduler. - */ - createSchedulerModel(), - createHeadlessCmsScheduleContext(params), - createSchedulerGraphQL() - ]; +export const createHeadlessCmsScheduler = (): PluginCollection => { + return [createHeadlessCmsScheduleContext(), createSchedulerGraphQL()]; }; diff --git a/packages/api-headless-cms-scheduler/src/manifest.ts b/packages/api-headless-cms-scheduler/src/manifest.ts deleted file mode 100644 index 2ba1e371ab0..00000000000 --- a/packages/api-headless-cms-scheduler/src/manifest.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ServiceDiscovery } from "@webiny/api"; -import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import { createZodError } from "@webiny/utils"; -import zod from "zod"; - -const schema = zod.object({ - scheduler: zod.object({ - lambdaArn: zod.string(), - roleArn: zod.string() - }) -}); - -export interface IGetManifestErrorResult { - error: Error; - data?: never; -} - -export interface IGetManifestSuccessResult { - data: { - lambdaArn: string; - roleArn: string; - }; - error?: never; -} - -export type IGetManifestResult = IGetManifestSuccessResult | IGetManifestErrorResult; - -export interface IGetManifestParams { - client: DynamoDBDocument; -} - -export const getManifest = async (params: IGetManifestParams): Promise => { - try { - ServiceDiscovery.setDocumentClient(params.client); - const manifest = await ServiceDiscovery.load(); - if (!manifest) { - return { - error: new Error("Manifest could not be loaded.") - }; - } else if (!manifest.scheduler) { - return { - error: new Error("Scheduler not found in the Manifest.") - }; - } - - const result = await schema.safeParseAsync(manifest); - if (!result.success) { - return { - error: createZodError(result.error) - }; - } - - return { - data: result.data.scheduler - }; - } catch (ex) { - return { - error: ex - }; - } -}; diff --git a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleExecutor.ts b/packages/api-headless-cms-scheduler/src/scheduler/ScheduleExecutor.ts deleted file mode 100644 index cd6c761d278..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleExecutor.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ScheduleType } from "~/scheduler/types.js"; -import { - type IScheduleAction, - type IScheduleExecutor, - type IScheduleFetcher, - type IScheduleRecord, - type ISchedulerInput -} from "~/scheduler/types.js"; -import { createScheduleRecordIdWithVersion } from "~/scheduler/createScheduleRecordId.js"; -import type { PublishScheduleActionCms } from "~/scheduler/actions/PublishScheduleAction.js"; -import type { UnpublishScheduleActionCms } from "~/scheduler/actions/UnpublishScheduleAction.js"; -import { WebinyError } from "@webiny/error"; - -export type ScheduleExecutorCms = UnpublishScheduleActionCms & PublishScheduleActionCms; - -export interface IScheduleExecutorParams { - actions: IScheduleAction[]; - fetcher: IScheduleFetcher; -} - -export class ScheduleExecutor implements IScheduleExecutor { - private readonly actions: IScheduleAction[]; - private readonly fetcher: Pick; - - constructor(params: IScheduleExecutorParams) { - this.actions = params.actions; - this.fetcher = params.fetcher; - } - - public async schedule(targetId: string, input: ISchedulerInput): Promise { - const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); - const original = await this.fetcher.getScheduled(targetId); - - const action = this.getAction(input.type); - - if (original) { - return action.reschedule(original, input); - } - - return await action.schedule({ - scheduleRecordId, - targetId, - input - }); - } - - public async cancel(initialId: string): Promise { - const id = createScheduleRecordIdWithVersion(initialId); - const original = await this.fetcher.getScheduled(id); - if (!original) { - throw new WebinyError( - `No scheduled record found for ID "${id}".`, - "SCHEDULED_RECORD_NOT_FOUND", - { - id - } - ); - } - - const action = this.getAction(original.type); - await action.cancel(original.id); - return original; - } - - private getAction(type: ScheduleType): IScheduleAction { - const action = this.actions.find(action => { - return action.canHandle({ - type - }); - }); - if (action) { - return action; - } - throw new WebinyError(`No action found for input type "${type}".`, "NO_ACTION_FOUND", { - type - }); - } -} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleFetcher.ts b/packages/api-headless-cms-scheduler/src/scheduler/ScheduleFetcher.ts deleted file mode 100644 index 72ee37dc055..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleFetcher.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; -import type { - IScheduleEntryValues, - IScheduleFetcher, - IScheduleRecord, - ISchedulerListParams, - ISchedulerListResponse -} from "~/scheduler/types.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { createScheduleRecordIdWithVersion } from "~/scheduler/createScheduleRecordId.js"; -import { transformScheduleEntry } from "~/scheduler/ScheduleRecord.js"; -import { convertException } from "@webiny/utils"; - -export type ScheduleFetcherCms = Pick; - -export interface IScheduleFetcherParams { - cms: ScheduleFetcherCms; - targetModel: CmsModel; - schedulerModel: CmsModel; -} - -export class ScheduleFetcher implements IScheduleFetcher { - private readonly cms: ScheduleFetcherCms; - private readonly targetModel: CmsModel; - private readonly schedulerModel: CmsModel; - - constructor(params: IScheduleFetcherParams) { - this.cms = params.cms; - this.schedulerModel = params.schedulerModel; - this.targetModel = params.targetModel; - } - - public async getScheduled(targetId: string): Promise { - const scheduleRecordId = createScheduleRecordIdWithVersion(targetId); - try { - const entry = await this.cms.getEntryById( - this.schedulerModel, - scheduleRecordId - ); - if (entry.values.targetModelId !== this.targetModel.modelId) { - return null; - } - return transformScheduleEntry(this.targetModel, entry); - } catch (ex) { - if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) { - return null; - } - console.error(`Error while fetching scheduled record: ${targetId}.`); - console.log(convertException(ex)); - throw ex; - } - } - - public async listScheduled(params: ISchedulerListParams): Promise { - const [data, meta] = await this.cms.listLatestEntries( - this.schedulerModel, - { - sort: params.sort, - limit: params.limit, - /** - * When params - */ - where: { - ...params.where, - targetModelId: this.targetModel.modelId - }, - after: params.after - } - ); - - return { - data: data.map(item => transformScheduleEntry(this.targetModel, item)), - meta - }; - } -} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleRecord.ts b/packages/api-headless-cms-scheduler/src/scheduler/ScheduleRecord.ts deleted file mode 100644 index 7cd37e2a99d..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/ScheduleRecord.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - type IScheduleEntryValues, - type IScheduleRecord, - type ScheduledOnType, - ScheduleType -} from "~/scheduler/types.js"; -import type { CmsEntry, CmsIdentity, CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { WebinyError } from "@webiny/error"; - -export interface IScheduleRecordParams { - id: string; - targetId: string; - model: CmsModel; - scheduledBy: CmsIdentity; - /** - * The date when the schedule is to be executed. - */ - scheduledOn: ScheduledOnType; - /** - * The date when the action is to be set as done. - * User can set publishedOn (and other relevant dates) with this parameter. - */ - // dateOn: DateOnType | undefined; - type: ScheduleType; - title: string; -} - -export class ScheduleRecord implements IScheduleRecord { - public readonly id: string; - public readonly targetId: string; - public readonly model: CmsModel; - public readonly scheduledBy: CmsIdentity; - public readonly publishOn: ScheduledOnType | undefined; - public readonly unpublishOn: ScheduledOnType | undefined; - // public readonly dateOn: DateOnType | undefined; - public readonly type: ScheduleType; - public readonly title: string; - - public constructor(record: IScheduleRecordParams) { - this.id = record.id; - this.targetId = record.targetId; - this.model = record.model; - this.scheduledBy = record.scheduledBy; - // this.dateOn = record.dateOn; - this.publishOn = record.type === ScheduleType.publish ? record.scheduledOn : undefined; - this.unpublishOn = record.type === ScheduleType.unpublish ? record.scheduledOn : undefined; - this.type = record.type; - this.title = record.title; - } -} - -export const createScheduleRecord = (record: IScheduleRecordParams): IScheduleRecord => { - return new ScheduleRecord(record); -}; - -export const transformScheduleEntry = ( - targetModel: CmsModel, - entry: CmsEntry -): IScheduleRecord => { - let type: ScheduleType; - switch (entry.values.type) { - case ScheduleType.publish: - type = ScheduleType.publish; - break; - case ScheduleType.unpublish: - type = ScheduleType.unpublish; - break; - default: - throw new WebinyError( - `Unsupported schedule type "${entry.values.type}".`, - "UNSUPPORTED_SCHEDULE_TYPE", - { - type: entry.values.type, - entry - } - ); - } - return createScheduleRecord({ - id: entry.id, - type, - title: entry.values.title, - targetId: entry.values.targetId, - scheduledOn: new Date(entry.values.scheduledOn), - // dateOn: isoStringToDate(entry.values.dateOn), - scheduledBy: entry.savedBy, - model: targetModel - }); -}; diff --git a/packages/api-headless-cms-scheduler/src/scheduler/Scheduler.ts b/packages/api-headless-cms-scheduler/src/scheduler/Scheduler.ts deleted file mode 100644 index f1d3327700f..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/Scheduler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { - IScheduleExecutor, - IScheduleFetcher, - IScheduler, - IScheduleRecord, - ISchedulerInput, - ISchedulerListParams, - ISchedulerListResponse -} from "~/scheduler/types.js"; - -export interface ISchedulerParams { - fetcher: IScheduleFetcher; - executor: IScheduleExecutor; -} - -export class Scheduler implements IScheduler { - private readonly fetcher: IScheduleFetcher; - private readonly executor: IScheduleExecutor; - - public constructor(params: ISchedulerParams) { - this.fetcher = params.fetcher; - this.executor = params.executor; - } - - public async getScheduled(targetId: string): Promise { - return this.fetcher.getScheduled(targetId); - } - - public async listScheduled(params: ISchedulerListParams): Promise { - return this.fetcher.listScheduled(params); - } - - public async schedule(targetId: string, input: ISchedulerInput): Promise { - return this.executor.schedule(targetId, input); - } - - public async cancel(id: string): Promise { - return this.executor.cancel(id); - } -} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/actions/PublishScheduleAction.ts b/packages/api-headless-cms-scheduler/src/scheduler/actions/PublishScheduleAction.ts deleted file mode 100644 index 902c7491cb3..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/actions/PublishScheduleAction.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { - IScheduleAction, - IScheduleActionScheduleParams, - IScheduleEntryValues, - IScheduleFetcher, - IScheduleRecord, - ISchedulerInput -} from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { createScheduleRecord, transformScheduleEntry } from "~/scheduler/ScheduleRecord.js"; -import { convertException } from "@webiny/utils"; -import type { - CmsEntry, - CmsEntryValues, - CmsIdentity, - CmsModel, - HeadlessCms -} from "@webiny/api-headless-cms/types/index.js"; -import type { ISchedulerService } from "~/service/types.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { dateInTheFuture } from "~/utils/dateInTheFuture.js"; -import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; - -export type PublishScheduleActionCms = Pick< - HeadlessCms, - "getEntryById" | "publishEntry" | "createEntry" | "updateEntry" | "deleteEntry" ->; - -export interface IPublishScheduleActionParams { - service: ISchedulerService; - cms: PublishScheduleActionCms; - targetModel: CmsModel; - schedulerModel: CmsModel; - getIdentity: () => CmsIdentity; - fetcher: IScheduleFetcher; -} - -export class PublishScheduleAction implements IScheduleAction { - private readonly service: ISchedulerService; - private readonly cms: PublishScheduleActionCms; - private readonly targetModel: CmsModel; - private readonly schedulerModel: CmsModel; - private readonly getIdentity: () => CmsIdentity; - private readonly fetcher: IScheduleFetcher; - - public constructor(params: IPublishScheduleActionParams) { - this.service = params.service; - this.cms = params.cms; - this.targetModel = params.targetModel; - this.schedulerModel = params.schedulerModel; - this.getIdentity = params.getIdentity; - this.fetcher = params.fetcher; - } - - public canHandle(input: ISchedulerInput): boolean { - return input.type === ScheduleType.publish; - } - - public async schedule(params: IScheduleActionScheduleParams): Promise { - const { targetId, input, scheduleRecordId } = params; - - const targetEntry = await this.getTargetEntry(targetId); - - const title = targetEntry.values[this.targetModel.titleFieldId] || "Unknown entry title"; - const identity = this.getIdentity(); - - const currentDate = new Date(); - /** - * Immediately publish the entry if requested. - * No need to create a schedule entry or the service event. - */ - if (input.immediately) { - const publishedEntry = await this.cms.publishEntry(this.targetModel, targetId); - return createScheduleRecord({ - id: scheduleRecordId, - targetId, - model: this.targetModel, - scheduledBy: publishedEntry.savedBy, - scheduledOn: new Date(publishedEntry.savedOn), - // dateOn: currentDate, - type: ScheduleType.publish, - title - }); - } - /** - * If the entry is scheduled for a date in the past, we need to update it with publish information, if user sent something. - * No need to create a schedule entry or the service event. - */ - // - else if (dateInTheFuture(input.scheduleOn) === false) { - /** - * We need to update the entry with publish information because we cannot update it in the publishing process. - */ - await this.cms.updateEntry(this.targetModel, targetId, { - firstPublishedBy: identity, - firstPublishedOn: dateToISOString(input.scheduleOn), - lastPublishedOn: dateToISOString(input.scheduleOn), - lastPublishedBy: identity - }); - const publishedEntry = await this.cms.publishEntry(this.targetModel, targetId); - return createScheduleRecord({ - id: scheduleRecordId, - targetId, - model: this.targetModel, - scheduledBy: publishedEntry.savedBy, - scheduledOn: currentDate, - type: ScheduleType.publish, - title - }); - } - - const { id: scheduleEntryId } = parseIdentifier(scheduleRecordId); - const scheduleEntry = await this.cms.createEntry( - this.schedulerModel, - { - id: scheduleEntryId, - targetId, - targetModelId: this.targetModel.modelId, - title, - type: ScheduleType.publish, - scheduledOn: dateToISOString(input.scheduleOn), - scheduledBy: identity - } - ); - - try { - await this.service.create({ - id: scheduleRecordId, - scheduleOn: input.scheduleOn - }); - } catch (ex) { - console.error( - `Could not create service event for schedule entry: ${scheduleRecordId}. Deleting the schedule entry...` - ); - console.log(convertException(ex)); - try { - await this.cms.deleteEntry(this.schedulerModel, scheduleRecordId, { - force: true, - permanently: true - }); - } catch (err) { - console.error(`Error while deleting schedule entry: ${scheduleRecordId}.`); - console.log(convertException(err)); - throw err; - } - throw ex; - } - return transformScheduleEntry(this.targetModel, scheduleEntry); - } - - public async reschedule( - original: IScheduleRecord, - input: ISchedulerInput - ): Promise { - const currentDate = new Date(); - const targetId = original.targetId; - - const targetEntry = await this.getTargetEntry(targetId); - - /** - * There are two cases when we can immediately publish the entry: - * 1. If the user requested it. - * 2. If the entry is scheduled for a date in the past. - */ - if (input.immediately || input.scheduleOn < currentDate) { - await this.cms.publishEntry(this.targetModel, targetEntry.id); - /** - * We can safely cancel the original schedule entry and the event. - * - * // TODO determine if we want to ignore the error of the cancelation. - */ - try { - await this.cancel(original.id); - } catch { - // - } - - return { - ...original, - publishOn: currentDate, - unpublishOn: undefined - // dateOn: publishedEntry.lastPublishedOn - // ? new Date(publishedEntry.lastPublishedOn) - // : undefined - }; - } - - await this.cms.updateEntry>( - this.schedulerModel, - original.id, - { - scheduledBy: this.getIdentity(), - scheduledOn: dateToISOString(input.scheduleOn) - } - ); - - try { - await this.service.update({ - id: original.id, - scheduleOn: input.scheduleOn - }); - } catch (ex) { - throw ex; - } - return { - ...original, - publishOn: new Date(), - unpublishOn: undefined - }; - } - - public async cancel(id: string): Promise { - /** - * No need to do anything if the record does not exist. - */ - let scheduleEntry: IScheduleRecord | null = null; - try { - scheduleEntry = await this.fetcher.getScheduled(id); - if (!scheduleEntry) { - return; - } - } catch { - return; - } - - try { - await this.cms.deleteEntry(this.schedulerModel, scheduleEntry.id, { - force: true, - permanently: true - }); - } catch (ex) { - if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) { - return; - } - console.error(`Error while deleting schedule entry: ${scheduleEntry.id}.`); - console.log(convertException(ex)); - throw ex; - } - - try { - await this.service.delete(scheduleEntry.id); - } catch (ex) { - console.error( - `Error while deleting service event for schedule entry: ${scheduleEntry.id}.` - ); - console.log(convertException(ex)); - throw ex; - } - } - - private async getTargetEntry(id: string): Promise> { - return await this.cms.getEntryById(this.targetModel, id); - } -} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/actions/UnpublishScheduleAction.ts b/packages/api-headless-cms-scheduler/src/scheduler/actions/UnpublishScheduleAction.ts deleted file mode 100644 index fce7cc019b3..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/actions/UnpublishScheduleAction.ts +++ /dev/null @@ -1,254 +0,0 @@ -import type { - IScheduleAction, - IScheduleActionScheduleParams, - IScheduleEntryValues, - IScheduleFetcher, - IScheduleRecord, - ISchedulerInput -} from "~/scheduler/types.js"; -import { ScheduleType } from "~/scheduler/types.js"; -import { createScheduleRecord, transformScheduleEntry } from "~/scheduler/ScheduleRecord.js"; -import { convertException } from "@webiny/utils"; -import type { - CmsEntry, - CmsEntryValues, - CmsIdentity, - CmsModel, - HeadlessCms -} from "@webiny/api-headless-cms/types/index.js"; -import type { ISchedulerService } from "~/service/types.js"; -import { dateToISOString } from "~/scheduler/dates.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { dateInTheFuture } from "~/utils/dateInTheFuture.js"; -import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; - -export type UnpublishScheduleActionCms = Pick< - HeadlessCms, - "getEntryById" | "unpublishEntry" | "createEntry" | "deleteEntry" | "updateEntry" ->; - -export interface IUnpublishScheduleActionParams { - service: ISchedulerService; - cms: UnpublishScheduleActionCms; - targetModel: CmsModel; - schedulerModel: CmsModel; - getIdentity: () => CmsIdentity; - fetcher: IScheduleFetcher; -} - -export class UnpublishScheduleAction implements IScheduleAction { - private readonly service: ISchedulerService; - private readonly cms: UnpublishScheduleActionCms; - private readonly targetModel: CmsModel; - private readonly schedulerModel: CmsModel; - private readonly getIdentity: () => CmsIdentity; - private readonly fetcher: IScheduleFetcher; - - public constructor(params: IUnpublishScheduleActionParams) { - this.service = params.service; - this.cms = params.cms; - this.targetModel = params.targetModel; - this.schedulerModel = params.schedulerModel; - this.getIdentity = params.getIdentity; - this.fetcher = params.fetcher; - } - - public canHandle(input: ISchedulerInput): boolean { - return input.type === ScheduleType.unpublish; - } - - public async schedule(params: IScheduleActionScheduleParams): Promise { - const { targetId, input, scheduleRecordId } = params; - - const targetEntry = await this.getTargetEntry(targetId); - const title = targetEntry.values[this.targetModel.titleFieldId] || "Unknown entry title"; - const identity = this.getIdentity(); - - const currentDate = new Date(); - /** - * Immediately unpublish the entry if requested. - */ - if (input.immediately) { - const unpublishedEntry = await this.cms.unpublishEntry(this.targetModel, targetId); - return createScheduleRecord({ - id: scheduleRecordId, - targetId, - model: this.targetModel, - scheduledBy: unpublishedEntry.savedBy, - scheduledOn: currentDate, - // dateOn: currentDate, - type: ScheduleType.unpublish, - title - }); - } - /** - * If the entry is scheduled for a date in the past, we need to unpublish it immediately. - * No need to create a schedule entry or the service event. - */ - // - else if (input.scheduleOn < currentDate) { - await this.cms.unpublishEntry(this.targetModel, targetId); - return createScheduleRecord({ - id: scheduleRecordId, - targetId, - model: this.targetModel, - scheduledBy: identity, - scheduledOn: input.scheduleOn, - // dateOn: input.dateOn, - type: ScheduleType.unpublish, - title - }); - } - /** - * If the entry is scheduled for a future date, we need to create a schedule entry and a service event. - */ - - const { id: scheduleEntryId } = parseIdentifier(scheduleRecordId); - const scheduleEntry = await this.cms.createEntry( - this.schedulerModel, - { - id: scheduleEntryId, - targetId, - targetModelId: this.targetModel.modelId, - title, - type: ScheduleType.unpublish, - // dateOn: input.dateOn ? dateToISOString(input.dateOn) : undefined, - scheduledBy: identity, - scheduledOn: dateToISOString(input.scheduleOn) - } - ); - - try { - await this.service.create({ - id: scheduleRecordId, - scheduleOn: input.scheduleOn - }); - } catch (ex) { - console.error( - `Could not create service event for schedule entry: ${scheduleRecordId}. Deleting the schedule entry...` - ); - console.log(convertException(ex)); - try { - await this.cms.deleteEntry(this.schedulerModel, scheduleRecordId, { - force: true, - permanently: true - }); - } catch (err) { - console.error(`Error while deleting schedule entry: ${scheduleRecordId}.`); - console.log(convertException(err)); - throw err; - } - throw ex; - } - - return transformScheduleEntry(this.targetModel, scheduleEntry); - } - - public async reschedule( - original: IScheduleRecord, - input: ISchedulerInput - ): Promise { - const currentDate = new Date(); - const targetId = original.targetId; - - const targetEntry = await this.getTargetEntry(targetId); - /** - * There are two cases when we can immediately publish the entry: - * 1. If the user requested it. - * 2. If the entry is scheduled for a date in the past. - */ - if (input.immediately || dateInTheFuture(input.scheduleOn)) { - await this.cms.unpublishEntry(this.targetModel, targetEntry.id); - /** - * We can safely cancel the original schedule entry and the event. - * - * // TODO determine if we want to ignore the error of the cancelation. - */ - try { - await this.cancel(original.id); - } catch { - // - } - - return { - ...original, - publishOn: undefined, - unpublishOn: currentDate - // dateOn: publishedEntry.lastPublishedOn - // ? new Date(publishedEntry.lastPublishedOn) - // : undefined - }; - } - - await this.cms.updateEntry>( - this.schedulerModel, - original.id, - { - scheduledOn: dateToISOString(input.scheduleOn) - // dateOn: input.dateOn ? dateToISOString(input.dateOn) : undefined - } - ); - - try { - await this.service.update({ - id: original.id, - scheduleOn: input.scheduleOn - }); - } catch (ex) { - console.error(`Could not update service event for schedule entry: ${original.id}.`); - console.log(convertException(ex)); - throw ex; - } - - return { - ...original, - publishOn: undefined, - unpublishOn: currentDate - // dateOn: input.dateOn - }; - } - - public async cancel(id: string): Promise { - /** - * No need to do anything if the record does not exist. - */ - let scheduleRecord: IScheduleRecord | null = null; - try { - scheduleRecord = await this.fetcher.getScheduled(id); - if (!scheduleRecord) { - return; - } - } catch { - return; - } - - try { - await this.cms.deleteEntry(this.schedulerModel, scheduleRecord.id, { - force: true, - permanently: true - }); - } catch (ex) { - if (ex.code === "NOT_FOUND" || ex instanceof NotFoundError) { - return; - } - console.error(`Error while deleting schedule entry: ${scheduleRecord.id}.`); - console.log(convertException(ex)); - throw ex; - } - - try { - await this.service.delete(scheduleRecord.id); - } catch (ex) { - console.error( - `Error while deleting service event for schedule entry: ${scheduleRecord.id}.` - ); - console.log(convertException(ex)); - - throw ex; - } - } - - private async getTargetEntry(id: string): Promise> { - return await this.cms.getEntryById(this.targetModel, id); - } -} diff --git a/packages/api-headless-cms-scheduler/src/scheduler/createScheduleRecordId.ts b/packages/api-headless-cms-scheduler/src/scheduler/createScheduleRecordId.ts deleted file mode 100644 index 16a3e792315..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/createScheduleRecordId.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { parseIdentifier } from "@webiny/utils/parseIdentifier.js"; -import { SCHEDULE_ID_PREFIX } from "~/constants.js"; -import { zeroPad } from "@webiny/utils/zeroPad.js"; - -export const createScheduleRecordIdWithVersion = (input: string): string => { - const recordId = createScheduleRecordId(input); - const { id } = parseIdentifier(recordId); - - return `${id}#0001`; -}; - -export const createScheduleRecordId = (input: string): string => { - /** - * A possibility that the input is already a schedule record ID? - */ - if (input.includes(SCHEDULE_ID_PREFIX)) { - return input; - } - - const { id, version } = parseIdentifier(input); - return `${SCHEDULE_ID_PREFIX}${id}-${zeroPad(version || 1)}`; -}; diff --git a/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts b/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts deleted file mode 100644 index 5912acde25c..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/createScheduler.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ISchedulerService } from "~/service/types.js"; -import type { CmsContext, CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import type { IScheduler } from "./types.js"; -import { Scheduler } from "./Scheduler.js"; -import type { ScheduleFetcherCms } from "./ScheduleFetcher.js"; -import { ScheduleFetcher } from "./ScheduleFetcher.js"; -import type { ScheduleExecutorCms } from "./ScheduleExecutor.js"; -import { ScheduleExecutor } from "./ScheduleExecutor.js"; -import { PublishScheduleAction } from "~/scheduler/actions/PublishScheduleAction.js"; -import { UnpublishScheduleAction } from "~/scheduler/actions/UnpublishScheduleAction.js"; -import { WebinyError } from "@webiny/error"; - -export interface ICreateSchedulerParams { - security: Pick; - cms: ScheduleExecutorCms & ScheduleFetcherCms; - service: ISchedulerService; - schedulerModel: CmsModel; -} - -type CmsScheduleCallable = (targetModel: CmsModel) => IScheduler; - -export const createScheduler = async ( - params: ICreateSchedulerParams -): Promise => { - const { cms, security, schedulerModel, service } = params; - - return (targetModel: CmsModel): IScheduler => { - if (targetModel.isPrivate) { - throw new WebinyError( - "Cannot create a scheduler for private models.", - "PRIVATE_MODEL_ERROR", - { - modelId: targetModel.modelId - } - ); - } - const getIdentity = () => { - const identity = security.getIdentity(); - if (!identity) { - throw new Error("No identity found in security context."); - } - return identity; - }; - - const fetcher = new ScheduleFetcher({ - targetModel, - schedulerModel, - cms - }); - - const actions = [ - new PublishScheduleAction({ - cms, - schedulerModel, - targetModel, - service, - getIdentity, - fetcher - }), - new UnpublishScheduleAction({ - cms, - schedulerModel, - targetModel, - service, - getIdentity, - fetcher - }) - ]; - - const executor = new ScheduleExecutor({ - actions, - fetcher - }); - return new Scheduler({ - fetcher, - executor - }); - }; -}; diff --git a/packages/api-headless-cms-scheduler/src/scheduler/model.ts b/packages/api-headless-cms-scheduler/src/scheduler/model.ts deleted file mode 100644 index c407784c43a..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/model.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { createPrivateModelPlugin } from "@webiny/api-headless-cms/plugins/index.js"; -import { SCHEDULE_MODEL_ID } from "~/constants.js"; - -export const createSchedulerModel = () => { - return createPrivateModelPlugin({ - noValidate: true, - modelId: SCHEDULE_MODEL_ID, - name: "Webiny CMS Schedule", - fields: [ - { - id: "targetId", - fieldId: "targetId", - storageId: "text@targetId", - type: "text", - label: "Target ID" - }, - { - id: "targetModelId", - fieldId: "targetModelId", - storageId: "text@targetModelId", - type: "text", - label: "Target Model ID" - }, - { - id: "scheduledBy", - fieldId: "scheduledBy", - storageId: "text@scheduledBy", - type: "object", - label: "Scheduled By", - settings: { - fields: [ - { - id: "id", - fieldId: "id", - storageId: "text@id", - type: "text", - label: "Identity ID" - }, - { - id: "displayName", - fieldId: "displayName", - storageId: "text@displayName", - type: "text", - label: "Display Name" - }, - { - id: "type", - fieldId: "type", - storageId: "text@type", - type: "text", - label: "Type" - } - ] - } - }, - { - id: "scheduledOn", - fieldId: "scheduledOn", - storageId: "date@scheduledOn", - type: "datetime", - label: "Scheduled On" - }, - { - id: "dateOn", - fieldId: "dateOn", - storageId: "date@dateOn", - type: "datetime", - label: "Date On" - }, - { - id: "type", - fieldId: "type", - storageId: "text@type", - type: "text", - label: "Type" - }, - { - id: "title", - fieldId: "title", - storageId: "text@title", - type: "text", - label: "Title" - }, - { - id: "error", - fieldId: "error", - storageId: "text@error", - type: "text", - label: "Error" - } - ] - }); -}; diff --git a/packages/api-headless-cms-scheduler/src/scheduler/types.ts b/packages/api-headless-cms-scheduler/src/scheduler/types.ts deleted file mode 100644 index 684e683868a..00000000000 --- a/packages/api-headless-cms-scheduler/src/scheduler/types.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { - CmsEntryListSort, - CmsEntryMeta, - CmsIdentity, - CmsModel -} from "@webiny/api-headless-cms/types/index.js"; - -export enum ScheduleType { - publish = "publish", - unpublish = "unpublish" -} - -export type DateISOString = - `${number}-${number}-${number}T${number}:${number}:${number}.${number}Z`; -/** - * A date when the action is to be scheduled. - */ -export type ScheduledOnType = Date; -/** - * A custom date when the action is to be set as done (publishedOn and related dates). - */ -export type DateOnType = Date; - -export interface ISchedulerInputImmediately { - immediately: true; - scheduleOn?: never; - type: ScheduleType; -} - -export interface ISchedulerInputScheduled { - immediately?: false; - scheduleOn: ScheduledOnType; - type: ScheduleType; -} - -export type ISchedulerInput = ISchedulerInputScheduled | ISchedulerInputImmediately; - -export interface IScheduleRecord { - id: string; - targetId: string; - model: CmsModel; - scheduledBy: CmsIdentity; - // dateOn: DateOnType | undefined; - publishOn: ScheduledOnType | undefined; - unpublishOn: ScheduledOnType | undefined; - type: ScheduleType; - title: string; -} - -export interface ISchedulerListResponse { - data: IScheduleRecord[]; - meta: CmsEntryMeta; -} - -export interface ISchedulerListParamsWhere { - targetId?: string; - targetEntryId?: string; - type?: ScheduleType; - scheduledBy?: string; - scheduledOn?: DateISOString; - scheduledOn_gte?: DateISOString; - scheduledOn_lte?: DateISOString; -} - -export interface ISchedulerListParams { - where: ISchedulerListParamsWhere; - sort: CmsEntryListSort | undefined; - limit: number | undefined; - after: string | undefined; -} - -export interface IScheduler { - schedule(id: string, input: ISchedulerInput): Promise; - cancel(id: string): Promise; - getScheduled(id: string): Promise; - listScheduled(params: ISchedulerListParams): Promise; -} - -export interface IScheduleEntryValues { - targetId: string; - targetModelId: string; - scheduledBy: CmsIdentity; - // dateOn: DateISOString | undefined; - scheduledOn: DateISOString; - type: string; - title: string; - error?: string; -} - -export interface IScheduleExecutor { - schedule(targetId: string, input: ISchedulerInput): Promise; - cancel(id: string): Promise; -} - -export interface IScheduleFetcher { - getScheduled(targetId: string): Promise; - listScheduled(params: ISchedulerListParams): Promise; -} - -export interface IScheduleActionScheduleParams { - targetId: string; - scheduleRecordId: string; - input: ISchedulerInput; -} -export interface IScheduleAction { - canHandle(input: Pick): boolean; - schedule(params: IScheduleActionScheduleParams): Promise; - cancel(id: string): Promise; - reschedule(original: IScheduleRecord, input: ISchedulerInput): Promise; -} diff --git a/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts b/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts deleted file mode 100644 index e549d4177db..00000000000 --- a/packages/api-headless-cms-scheduler/src/service/SchedulerService.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { - CreateScheduleCommandInput, - DeleteScheduleCommandInput, - GetScheduleCommandInput, - SchedulerClient, - SchedulerClientConfig, - UpdateScheduleCommandInput -} from "@webiny/aws-sdk/client-scheduler/index.js"; -import { - CreateScheduleCommand, - DeleteScheduleCommand, - GetScheduleCommand, - UpdateScheduleCommand -} from "@webiny/aws-sdk/client-scheduler/index.js"; -import type { - ISchedulerService, - ISchedulerServiceCreateInput, - ISchedulerServiceCreateResponse, - ISchedulerServiceDeleteResponse, - ISchedulerServiceUpdateInput, - ISchedulerServiceUpdateResponse -} from "./types.js"; -import { WebinyError } from "@webiny/error"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { dateInTheFuture } from "~/utils/dateInTheFuture.js"; -import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { parseIdentifier } from "@webiny/utils"; -import type { IWebinyScheduledCmsActionEventValues } from "~/features/ProcessRecords/abstractions.js"; - -export interface ISchedulerServiceParams { - getClient(config?: SchedulerClientConfig): Pick; - config: ISchedulerServiceConfig; -} - -export interface ISchedulerServiceConfig { - lambdaArn: string; - roleArn: string; -} - -export class SchedulerService implements ISchedulerService { - private readonly getClient: (config?: SchedulerClientConfig) => Pick; - private readonly config: ISchedulerServiceConfig; - - public constructor(params: ISchedulerServiceParams) { - this.getClient = params.getClient; - this.config = params.config; - } - - public async create( - params: ISchedulerServiceCreateInput - ): Promise { - const { id: initialId, scheduleOn } = params; - - const { id } = parseIdentifier(initialId); - - if (dateInTheFuture(scheduleOn) === false) { - throw new WebinyError( - `Cannot create a schedule for "${id}" with date in the past: ${scheduleOn.toISOString()}`, - "SCHEDULE_DATE_IN_PAST", - { - id, - dateOn: scheduleOn.toISOString() - } - ); - } - /** - * There is a possibility that the schedule already exists, in which case we will just update it. - * - * TODO determine if we want to allow this behavior - or throw an error if the schedule already exists. - */ - const exists = await this.exists(id); - if (exists) { - return this.update(params); - } - const input: CreateScheduleCommandInput = this.getInput(id, scheduleOn); - const command = new CreateScheduleCommand(input); - try { - return await this.getClient().send(command); - } catch (ex) { - console.error(ex); - throw WebinyError.from(ex); - } - } - - public async update( - params: ISchedulerServiceUpdateInput - ): Promise { - const { id: initialId, scheduleOn } = params; - - const { id } = parseIdentifier(initialId); - - if (dateInTheFuture(scheduleOn) === false) { - throw new WebinyError( - `Cannot update an existing schedule for "${id}" with date in the past: ${scheduleOn.toISOString()}`, - "SCHEDULE_DATE_IN_PAST", - { - id, - dateOn: scheduleOn.toISOString() - } - ); - } - /** - * If the schedule does not exist, we cannot update it. - * - * TODO determine if we want to create a new schedule in this case, or return the error. - */ - const exists = await this.exists(id); - if (!exists) { - throw new NotFoundError(`Cannot update schedule "${id}" because it does not exist.`); - } - const input: UpdateScheduleCommandInput = this.getInput(id, scheduleOn); - const command = new UpdateScheduleCommand(input); - try { - return await this.getClient().send(command); - } catch (ex) { - throw WebinyError.from(ex); - } - } - - public async delete(initialId: string): Promise { - const { id } = parseIdentifier(initialId); - - const exists = await this.exists(id); - if (!exists) { - throw new NotFoundError(`Cannot delete schedule "${id}" because it does not exist.`); - } - - const input: DeleteScheduleCommandInput = { - Name: id - }; - const command = new DeleteScheduleCommand(input); - try { - return await this.getClient().send(command); - } catch (ex) { - throw WebinyError.from(ex); - } - } - - public async exists(initialId: string): Promise { - const { id } = parseIdentifier(initialId); - - const input: GetScheduleCommandInput = { - Name: id - }; - const command = new GetScheduleCommand(input); - try { - const result = await this.getClient().send(command); - return result.$metadata?.httpStatusCode === 200; - } catch { - return false; - } - } - - private createScheduleExpression(input: Date): string { - return `at(${this.getTimeAt(input)})`; - } - - private getTimeAt(input: Date): string { - return input.toISOString().replace(".000Z", ""); - } - - private getInput( - initialId: string, - scheduleOn: Date - ): CreateScheduleCommandInput | UpdateScheduleCommandInput { - const { id } = parseIdentifier(initialId); - - const values: IWebinyScheduledCmsActionEventValues = { - id, - scheduleOn: scheduleOn.toISOString() - }; - return { - Name: id, - ActionAfterCompletion: "DELETE", - ScheduleExpression: this.createScheduleExpression(scheduleOn), - FlexibleTimeWindow: { - Mode: "OFF" - }, - Target: { - Arn: this.config.lambdaArn, - RoleArn: this.config.roleArn, - Input: JSON.stringify({ - [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: values - }) - } - }; - } -} - -export const createSchedulerService = (params: ISchedulerServiceParams): ISchedulerService => { - return new SchedulerService(params); -}; diff --git a/packages/api-headless-cms-scheduler/src/service/types.ts b/packages/api-headless-cms-scheduler/src/service/types.ts deleted file mode 100644 index 15159261973..00000000000 --- a/packages/api-headless-cms-scheduler/src/service/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ScheduledOnType } from "~/scheduler/types.js"; -import type { - CreateScheduleCommandOutput, - DeleteScheduleCommandOutput, - UpdateScheduleCommandOutput -} from "@webiny/aws-sdk/client-scheduler/index.js"; - -export interface ISchedulerServiceCreateInput { - id: string; - scheduleOn: ScheduledOnType; -} - -export interface ISchedulerServiceUpdateInput { - id: string; - scheduleOn: ScheduledOnType; -} - -export type ISchedulerServiceCreateResponse = CreateScheduleCommandOutput; - -export type ISchedulerServiceUpdateResponse = UpdateScheduleCommandOutput; - -export type ISchedulerServiceDeleteResponse = DeleteScheduleCommandOutput; - -export interface ISchedulerService { - create(params: ISchedulerServiceCreateInput): Promise; - update(params: ISchedulerServiceUpdateInput): Promise; - delete(id: string): Promise; - exists(id: string): Promise; -} diff --git a/packages/api-headless-cms-scheduler/tsconfig.build.json b/packages/api-headless-cms-scheduler/tsconfig.build.json index d50beaf8abd..62c81c60ff4 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.build.json +++ b/packages/api-headless-cms-scheduler/tsconfig.build.json @@ -4,7 +4,7 @@ "references": [ { "path": "../api/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, - { "path": "../aws-sdk/tsconfig.build.json" }, + { "path": "../api-scheduler/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, @@ -26,8 +26,23 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], - "@webiny/aws-sdk": ["../aws-sdk/src"], + "@webiny/api-scheduler/features/ScheduleAction": [ + "../api-scheduler/src/features/ScheduleAction/index.js" + ], + "@webiny/api-scheduler/features/GetScheduledAction": [ + "../api-scheduler/src/features/GetScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ListScheduledActions": [ + "../api-scheduler/src/features/ListScheduledActions/index.js" + ], + "@webiny/api-scheduler/features/CancelScheduledAction": [ + "../api-scheduler/src/features/CancelScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ExecuteScheduledAction": [ + "../api-scheduler/src/features/ExecuteScheduledAction/index.js" + ], + "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], + "@webiny/api-scheduler": ["../api-scheduler/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], diff --git a/packages/api-headless-cms-scheduler/tsconfig.json b/packages/api-headless-cms-scheduler/tsconfig.json index 736d659f86a..c07721e66f2 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.json +++ b/packages/api-headless-cms-scheduler/tsconfig.json @@ -4,7 +4,7 @@ "references": [ { "path": "../api" }, { "path": "../api-headless-cms" }, - { "path": "../aws-sdk" }, + { "path": "../api-scheduler" }, { "path": "../error" }, { "path": "../feature" }, { "path": "../handler-graphql" }, @@ -26,8 +26,23 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], - "@webiny/aws-sdk": ["../aws-sdk/src"], + "@webiny/api-scheduler/features/ScheduleAction": [ + "../api-scheduler/src/features/ScheduleAction/index.js" + ], + "@webiny/api-scheduler/features/GetScheduledAction": [ + "../api-scheduler/src/features/GetScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ListScheduledActions": [ + "../api-scheduler/src/features/ListScheduledActions/index.js" + ], + "@webiny/api-scheduler/features/CancelScheduledAction": [ + "../api-scheduler/src/features/CancelScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ExecuteScheduledAction": [ + "../api-scheduler/src/features/ExecuteScheduledAction/index.js" + ], + "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], + "@webiny/api-scheduler": ["../api-scheduler/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], diff --git a/packages/api-scheduler/__tests__/ScheduledActionId.test.ts b/packages/api-scheduler/__tests__/ScheduledActionId.test.ts index d3392a6cbf5..c9a02d71a11 100644 --- a/packages/api-scheduler/__tests__/ScheduledActionId.test.ts +++ b/packages/api-scheduler/__tests__/ScheduledActionId.test.ts @@ -10,6 +10,6 @@ describe("ScheduledActionId", () => { targetId: "target-id#0001" }); - expect(result).toEqual(`${SCHEDULE_ID_PREFIX}5aebe6a0ee483f0a203e729d`); + expect(result).toEqual(`${SCHEDULE_ID_PREFIX}af6fe9a3643c86f694da7bb5`); }); }); diff --git a/packages/api-scheduler/__tests__/Scheduler.test.ts b/packages/api-scheduler/__tests__/Scheduler.test.ts index 8969eb66eff..7d5cd1f83c3 100644 --- a/packages/api-scheduler/__tests__/Scheduler.test.ts +++ b/packages/api-scheduler/__tests__/Scheduler.test.ts @@ -6,7 +6,6 @@ import { ExecuteScheduledActionUseCase } from "~/features/ExecuteScheduledAction import { ScheduleActionUseCase } from "~/features/ScheduleAction/abstractions.js"; import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; import { ScheduledActionHandler } from "~/shared/abstractions.js"; -import type { IScheduledAction } from "~/shared/abstractions.js"; import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; import { ListScheduledActionsUseCase } from "~/features/ListScheduledActions/index.js"; import { CancelScheduledActionUseCase } from "~/features/CancelScheduledAction/index.js"; @@ -45,13 +44,13 @@ describe("Scheduler", () => { const executeScheduledAction = testContainer.resolve(ExecuteScheduledActionUseCase); // Schedule an action - const scheduleResult = await scheduleAction.execute( + const scheduleResult = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: new Date(Date.now() + 1000000) }, - { some: "payload" } - ); + input: { scheduleOn: new Date(Date.now() + 1000000) }, + payload: { some: "payload" } + }); expect(scheduleResult.isFail()).toBe(false); @@ -72,7 +71,7 @@ describe("Scheduler", () => { // Create a mock handler that tracks if it was called const mockHandler = { canHandle: vi.fn((ns: string, type: string) => ns === namespace && type === actionType), - handle: vi.fn(async (action: IScheduledAction) => { + handle: vi.fn(async () => { // Handler was invoked successfully }) }; @@ -84,13 +83,13 @@ describe("Scheduler", () => { const getScheduledAction = testContainer.resolve(GetScheduledActionUseCase); // Schedule an action - const scheduleResult = await scheduleAction.execute( + const scheduleResult = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: new Date(Date.now() + 1000000) }, - { some: "payload" } - ); + input: { scheduleOn: new Date(Date.now() + 1000000) }, + payload: { some: "payload" } + }); expect(scheduleResult.isFail()).toBe(false); @@ -143,13 +142,13 @@ describe("Scheduler", () => { const getScheduledAction = testContainer.resolve(GetScheduledActionUseCase); // Schedule an action - const scheduleResult = await scheduleAction.execute( + const scheduleResult = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: new Date(Date.now() + 1000000) }, - { some: "payload" } - ); + input: { scheduleOn: new Date(Date.now() + 1000000) }, + payload: { some: "payload" } + }); expect(scheduleResult.isFail()).toBe(false); @@ -186,13 +185,13 @@ describe("Scheduler", () => { const secondDate = new Date(Date.now() + 2000000); // Schedule first time - const firstResult = await scheduleAction.execute( + const firstResult = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: firstDate }, - { version: 1 } - ); + input: { scheduleOn: firstDate }, + payload: { version: 1 } + }); expect(firstResult.isFail()).toBe(false); @@ -203,13 +202,13 @@ describe("Scheduler", () => { expect(getFirstResult.value.payload).toEqual({ version: 1 }); // Reschedule (same namespace + actionType + targetId) - const secondResult = await scheduleAction.execute( + const secondResult = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: secondDate }, - { version: 2 } - ); + input: { scheduleOn: secondDate }, + payload: { version: 2 } + }); expect(secondResult.isFail()).toBe(false); @@ -229,26 +228,26 @@ describe("Scheduler", () => { const listScheduledActions = testContainer.resolve(ListScheduledActionsUseCase); // Schedule an action - const scheduleResult1 = await scheduleAction.execute( + const scheduleResult1 = await scheduleAction.execute({ namespace, actionType, targetId, - { scheduleOn: new Date(Date.now() + 1000000) }, - { some: "payload" } - ); + input: { scheduleOn: new Date(Date.now() + 1000000) }, + payload: { some: "payload" } + }); - const scheduleResult2 = await scheduleAction.execute( + const scheduleResult2 = await scheduleAction.execute({ namespace, - "ColonizeMars", + actionType: "ColonizeMars", targetId, - { scheduleOn: new Date(Date.now() + 1000000) }, - { some: "payload" } - ); + input: { scheduleOn: new Date(Date.now() + 1000000) }, + payload: { some: "payload" } + }); expect(scheduleResult1.isOk()).toBe(true); expect(scheduleResult2.isOk()).toBe(true); - const scheduledActionsResult = await listScheduledActions.execute({ where: { namespace } }); + const scheduledActionsResult = await listScheduledActions.execute({ where: { namespace, targetId } }); expect(scheduledActionsResult.isOk()).toBe(true); const scheduledActions = scheduledActionsResult.value.items; diff --git a/packages/api-scheduler/__tests__/eventHandler.test.ts b/packages/api-scheduler/__tests__/eventHandler.test.ts new file mode 100644 index 00000000000..e08a3667897 --- /dev/null +++ b/packages/api-scheduler/__tests__/eventHandler.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { RawEventHandler } from "@webiny/handler-aws/raw/index.js"; +import { + createScheduledActionEventHandler, + type IScheduledActionEvent +} from "~/createEventHandler.js"; +import { registry } from "@webiny/handler-aws/registry.js"; +import type { LambdaContext } from "@webiny/handler-aws/types.js"; +import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; +import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; + +describe("Scheduler Event Handler", () => { + const lambdaContext = {} as LambdaContext; + it("should trigger handle an event which matches scheduled event", async () => { + const eventHandler = createScheduledActionEventHandler(); + + expect(eventHandler).toBeInstanceOf(RawEventHandler); + + const event: IScheduledActionEvent = { + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: { + id: ScheduledActionId.from({ + namespace: "Cms/Entry/Article", + actionType: "Publish", + targetId: "target-id#0001" + }), + scheduleOn: new Date().toISOString() + } + }; + const sourceHandler = registry.getHandler(event, lambdaContext); + + expect(sourceHandler).toMatchObject({ + name: "handler-aws-event-bridge-scheduled-cms-action-event" + }); + expect(sourceHandler.canUse(event, lambdaContext)).toBe(true); + }); +}); diff --git a/packages/api-scheduler/__tests__/mocks/context/plugins.ts b/packages/api-scheduler/__tests__/mocks/context/plugins.ts index 18474516a7f..5251287995f 100644 --- a/packages/api-scheduler/__tests__/mocks/context/plugins.ts +++ b/packages/api-scheduler/__tests__/mocks/context/plugins.ts @@ -58,36 +58,6 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { identity }), createSchedulerManifestPlugin(), - // { - // type: "context", - // name: "context-security-tenant", - // async apply(context) { - // context.container.register() - // context.security.getApiKeyByToken = async ( - // token: string - // ): Promise => { - // if (!token || token !== "aToken") { - // return null; - // } - // const apiKey = "a1234567890"; - // return { - // id: apiKey, - // name: apiKey, - // tenant: tenant.id, - // permissions: identity?.permissions || [], - // token, - // createdBy: { - // id: "test", - // displayName: "test", - // type: "admin" - // }, - // description: "test", - // createdOn: new Date().toISOString(), - // webinyVersion: context.WEBINY_VERSION - // }; - // }; - // } - // } as ContextPlugin, apiKeyAuthentication({ identityType: "api-key" }), apiKeyAuthorization({ identityType: "api-key" }), createHeadlessCmsContext({ diff --git a/packages/api-scheduler/__tests__/mocks/schedulerModel.ts b/packages/api-scheduler/__tests__/mocks/schedulerModel.ts deleted file mode 100644 index 3b19b6e7b1f..00000000000 --- a/packages/api-scheduler/__tests__/mocks/schedulerModel.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { createSchedulerModel } from "~/domain/model.js"; - -export const createMockSchedulerModel = (input?: Partial): CmsModel => { - const model = createSchedulerModel(); - return { - ...model.contentModel, - webinyVersion: "0.0.0", - tenant: "root", - ...input - }; -}; diff --git a/packages/api-scheduler/__tests__/mocks/security.ts b/packages/api-scheduler/__tests__/mocks/security.ts deleted file mode 100644 index 952b933ca8b..00000000000 --- a/packages/api-scheduler/__tests__/mocks/security.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; -import { createMockGetIdentity } from "~tests/mocks/getIdentity.js"; - -export const createMockSecurity = ( - input?: Partial> -): Pick => { - const getIdentity = createMockGetIdentity(); - return { - // @ts-expect-error - getIdentity, - withoutAuthorization: (cb: () => Promise) => { - return cb(); - }, - ...input - }; -}; diff --git a/packages/api-scheduler/package.json b/packages/api-scheduler/package.json index 35b118d75b9..cfa7044db40 100644 --- a/packages/api-scheduler/package.json +++ b/packages/api-scheduler/package.json @@ -14,23 +14,34 @@ "author": "Webiny Ltd", "description": "A generic action scheduler.", "license": "MIT", + "exports": { + "./features/ScheduleAction": "./features/ScheduleAction/index.js", + "./features/GetScheduledAction": "./features/GetScheduledAction/index.js", + "./features/ListScheduledActions": "./features/ListScheduledActions/index.js", + "./features/CancelScheduledAction": "./features/CancelScheduledAction/index.js", + "./features/ExecuteScheduledAction": "./features/ExecuteScheduledAction/index.js", + ".": "./index.js" + }, "dependencies": { "@webiny/api": "0.0.0", "@webiny/api-core": "0.0.0", "@webiny/api-headless-cms": "0.0.0", + "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", - "@webiny/pubsub": "0.0.0", + "@webiny/utils": "0.0.0", "zod": "^3.25.76" }, "devDependencies": { "@webiny/build-tools": "0.0.0", "@webiny/db-dynamodb": "0.0.0", + "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/project-utils": "0.0.0", "@webiny/wcp": "0.0.0", + "aws-sdk-client-mock": "^4.1.0", "jest-dynalite": "^3.6.1", "rimraf": "^6.0.1", "typescript": "5.9.3", diff --git a/packages/api-headless-cms-scheduler/src/handler/index.ts b/packages/api-scheduler/src/createEventHandler.ts similarity index 56% rename from packages/api-headless-cms-scheduler/src/handler/index.ts rename to packages/api-scheduler/src/createEventHandler.ts index 8f2f8fcef11..d896ba95f19 100644 --- a/packages/api-headless-cms-scheduler/src/handler/index.ts +++ b/packages/api-scheduler/src/createEventHandler.ts @@ -3,15 +3,22 @@ import type { HandlerFactoryParams } from "@webiny/handler-aws/types.js"; import { createSourceHandler } from "@webiny/handler-aws/sourceHandler.js"; import { createEventHandler, createHandler } from "@webiny/handler-aws/raw/index.js"; import { SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER } from "~/constants.js"; -import { ProcessRecordsFeature } from "~/features/ProcessRecords/feature.js"; -import { ProcessRecordsUseCase } from "~/features/ProcessRecords/index.js"; -import type { IWebinyScheduledCmsActionEvent } from "~/features/ProcessRecords/abstractions.js"; +import { ExecuteScheduledActionUseCase } from "~/features/ExecuteScheduledAction/index.js"; + +export interface IScheduledActionEventPayload { + id: string; // id of the scheduled action + scheduleOn: string; +} + +export interface IScheduledActionEvent { + [SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]: IScheduledActionEventPayload; +} export interface HandlerParams extends HandlerFactoryParams { debug?: boolean; } -const canHandle = (event: Partial): boolean => { +const canHandle = (event: Partial): boolean => { if (typeof event?.hasOwnProperty !== "function") { return false; } else if (!event.hasOwnProperty(SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER)) { @@ -22,7 +29,7 @@ const canHandle = (event: Partial): boolean => { return !!(value?.id && value?.scheduleOn); }; -const handler = createSourceHandler({ +const handler = createSourceHandler({ name: "handler-aws-event-bridge-scheduled-cms-action-event", canUse: event => { return canHandle(event); @@ -34,21 +41,22 @@ const handler = createSourceHandler { - return createEventHandler({ +export const createScheduledActionEventHandler = () => { + return createEventHandler({ canHandle: event => { return canHandle(event); }, handle: async params => { const { payload, context } = params; + const input = payload[SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER]; - ProcessRecordsFeature.register(context.container); - - const processRecords = context.container.resolve(ProcessRecordsUseCase); - const result = await processRecords.execute(payload); + const executeScheduledAction = context.container.resolve(ExecuteScheduledActionUseCase); + const result = await executeScheduledAction.execute(input.id); if (result.isFail()) { - throw result.error; + const error = result.error; + console.error(error.code, error.message); + throw error; } } }); diff --git a/packages/api-scheduler/src/createScheduler.ts b/packages/api-scheduler/src/createScheduler.ts new file mode 100644 index 00000000000..a7e550e34ba --- /dev/null +++ b/packages/api-scheduler/src/createScheduler.ts @@ -0,0 +1,17 @@ +import type { Plugin } from "@webiny/plugins/types.js"; +import { SchedulerClient, SchedulerClientConfig } from "@webiny/aws-sdk/client-scheduler"; +import { createSchedulerContext } from "~/context.js"; +import { createScheduledActionEventHandler } from "~/createEventHandler.js"; + +export interface ICreateSchedulerParams { + getClient(config?: SchedulerClientConfig): Pick; +} +export const createScheduler = (params: ICreateSchedulerParams): Plugin[] => { + return [ + /** + * Handler for the Scheduled Action Event. + */ + createScheduledActionEventHandler(), + createSchedulerContext(params) + ]; +}; diff --git a/packages/api-scheduler/src/domain/ScheduledActionId.ts b/packages/api-scheduler/src/domain/ScheduledActionId.ts index 32dc885f1f6..b6ef8583b4c 100644 --- a/packages/api-scheduler/src/domain/ScheduledActionId.ts +++ b/packages/api-scheduler/src/domain/ScheduledActionId.ts @@ -3,6 +3,9 @@ import { SCHEDULE_ID_PREFIX } from "~/constants.js"; export class ScheduledActionId { static from(params: { namespace: string; actionType: string; targetId: string }) { - return [SCHEDULE_ID_PREFIX, createCacheKey(params).slice(-24)].join(""); + return [ + SCHEDULE_ID_PREFIX, + createCacheKey([params.namespace, params.actionType, params.targetId]).slice(-24) + ].join(""); } } diff --git a/packages/api-scheduler/src/domain/errors.ts b/packages/api-scheduler/src/domain/errors.ts index 1477a0e6650..01eda0a24ac 100644 --- a/packages/api-scheduler/src/domain/errors.ts +++ b/packages/api-scheduler/src/domain/errors.ts @@ -31,13 +31,13 @@ export class ScheduledActionPersistenceError extends BaseError<{ originalError: /** * Invalid schedule date error (e.g., scheduling in the past) */ -export class InvalidScheduleDateError extends BaseError<{ scheduleOn: Date; scheduleId?: string }> { +export class InvalidScheduleDateError extends BaseError<{ scheduleOn: Date }> { override readonly code = "Scheduler/ScheduledAction/InvalidDate" as const; - constructor(scheduleOn: Date, scheduleId?: string) { + constructor(scheduleOn: Date) { super({ message: "Cannot schedule in the past", - data: { scheduleOn, scheduleId } + data: { scheduleOn } }); } } diff --git a/packages/api-headless-cms-scheduler/src/utils/dateInTheFuture.ts b/packages/api-scheduler/src/domain/isValidDate.ts similarity index 87% rename from packages/api-headless-cms-scheduler/src/utils/dateInTheFuture.ts rename to packages/api-scheduler/src/domain/isValidDate.ts index 4e5f60ef631..d1c7cb4a2ef 100644 --- a/packages/api-headless-cms-scheduler/src/utils/dateInTheFuture.ts +++ b/packages/api-scheduler/src/domain/isValidDate.ts @@ -5,7 +5,7 @@ import { SCHEDULE_MIN_FUTURE_SECONDS } from "~/constants.js"; * We need to ensure that the date is at least a SCHEDULE_MIN_FUTURE_MINUTES minutes in the future. * Otherwise, we consider it as "immediate" and run the action right away. */ -export const dateInTheFuture = (date: Date): boolean => { +export const isValidDate = (date: Date): boolean => { const minDate = new Date(Date.now() + SCHEDULE_MIN_FUTURE_SECONDS * 1000); return date.getTime() >= minDate.getTime(); diff --git a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts index 6027a29777c..20dfc47149c 100644 --- a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts +++ b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts @@ -47,6 +47,7 @@ class ListScheduledActionsUseCaseImpl implements UseCaseAbstraction.Interface { // Transform entries to IScheduledAction format const scheduledActions: IScheduledAction[] = items.map(entry => ({ id: entry.entryId, + title: entry.values.title, namespace: entry.values.namespace, actionType: entry.values.actionType, targetId: entry.values.targetId, diff --git a/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts b/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts index 08a0dbf231f..79854358344 100644 --- a/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts +++ b/packages/api-scheduler/src/features/ListScheduledActions/abstractions.ts @@ -19,6 +19,7 @@ export type DateISOString = export interface IListScheduledActionsWhere { namespace?: string; + namespace_startsWith?: string; actionType?: string; targetId?: string; scheduledBy?: string; diff --git a/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts new file mode 100644 index 00000000000..3b24f76b931 --- /dev/null +++ b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts @@ -0,0 +1,41 @@ +import { Result } from "@webiny/feature/api"; +import { RunActionUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { ScheduleActionUseCase } from "~/features/ScheduleAction/abstractions.js"; +import type { IScheduledAction } from "~/shared/abstractions.js"; + +/** + * Schedules an action for immediate execution + * + * Flow: + * 1. Calculate the closest possible execution time + * 2. Delegate to ScheduleAction use case with calculated time + * + * Note: We add a small buffer to ensure EventBridge has time to process the schedule + */ +class RunActionUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private scheduleActionUseCase: ScheduleActionUseCase.Interface) {} + + async execute( + params: UseCaseAbstraction.Params + ): Promise> { + // Delegate to ScheduleAction + const result = await this.scheduleActionUseCase.execute({ + ...params, + title: "Unknown title", + // Calculate the soonest possible execution time. + // Add at least 90 seconds of buffer to ensure EventBridge can process the schedule. + input: { scheduleOn: new Date(Date.now() + 90000) } + }); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const RunActionUseCase = UseCaseAbstraction.createImplementation({ + implementation: RunActionUseCaseImpl, + dependencies: [ScheduleActionUseCase] +}); diff --git a/packages/api-scheduler/src/features/RunAction/abstractions.ts b/packages/api-scheduler/src/features/RunAction/abstractions.ts new file mode 100644 index 00000000000..678630209cd --- /dev/null +++ b/packages/api-scheduler/src/features/RunAction/abstractions.ts @@ -0,0 +1,45 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { IScheduledAction, ISchedulerInput } from "~/shared/abstractions.js"; +import { + ScheduledActionPersistenceError, + InvalidScheduleDateError, + SchedulerServiceError +} from "~/domain/errors.js"; + +/** + * RunActionUseCase - Schedule an action for immediate execution + * + * This is a convenience use case that wraps ScheduleAction and automatically + * calculates the closest possible execution time (current time + small buffer). + * + * Use this when you want to execute an action "immediately" without having to manually + * calculate the schedule date. + */ + +export interface IRunActionErrors { + persistence: ScheduledActionPersistenceError; + invalidDate: InvalidScheduleDateError; + schedulerService: SchedulerServiceError; +} + +type RunActionError = IRunActionErrors[keyof IRunActionErrors]; + +interface IRunActionParams { + namespace: string; + actionType: string; + targetId: string; + payload?: any; +} + +export interface IRunActionUseCase { + execute(params: IRunActionParams): Promise>; +} + +export const RunActionUseCase = createAbstraction("RunActionUseCase"); + +export namespace RunActionUseCase { + export type Interface = IRunActionUseCase; + export type Params = IRunActionParams; + export type Error = RunActionError; +} diff --git a/packages/api-scheduler/src/features/RunAction/feature.ts b/packages/api-scheduler/src/features/RunAction/feature.ts new file mode 100644 index 00000000000..ae1e1dc5cad --- /dev/null +++ b/packages/api-scheduler/src/features/RunAction/feature.ts @@ -0,0 +1,15 @@ +import { createFeature } from "@webiny/feature/api"; +import { RunActionUseCase } from "./RunActionUseCase.js"; + +/** + * RunAction Feature + * + * Provides the ability to schedule an action for immediate execution + * without having to manually calculate the schedule date. + */ +export const RunActionFeature = createFeature({ + name: "RunAction", + register(container) { + container.register(RunActionUseCase); + } +}); diff --git a/packages/api-scheduler/src/features/RunAction/index.ts b/packages/api-scheduler/src/features/RunAction/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-scheduler/src/features/RunAction/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts index f7f90ef020f..47c089568e3 100644 --- a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts +++ b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts @@ -8,9 +8,14 @@ import { ScheduleActionUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetScheduledActionUseCase } from "~/features/GetScheduledAction/abstractions.js"; import { ScheduledActionModel, SchedulerService } from "~/shared/abstractions.js"; import type { IScheduledAction, ISchedulerInput, Identity } from "~/shared/abstractions.js"; -import { ScheduledActionPersistenceError, SchedulerServiceError } from "~/domain/errors.js"; +import { + InvalidScheduleDateError, + ScheduledActionPersistenceError, + SchedulerServiceError +} from "~/domain/errors.js"; import { ScheduledActionId } from "~/domain/ScheduledActionId.js"; import { ScheduledActionIdWithVersion } from "~/domain/ScheduledActionIdWithVersion.js"; +import { isValidDate } from "~/domain/isValidDate.js"; /** * Schedules an action for future execution @@ -36,16 +41,16 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { ) {} async execute( - namespace: string, - actionType: string, - targetId: string, - input: ISchedulerInput, - payload?: any + params: UseCaseAbstraction.Params ): Promise> { const identity = this.identityContext.getIdentity(); + if (!isValidDate(params.input.scheduleOn)) { + return Result.fail(new InvalidScheduleDateError(params.input.scheduleOn)); + } + // Generate unique schedule ID - const actionId = ScheduledActionId.from({ namespace, actionType, targetId }); + const actionId = ScheduledActionId.from(params); const scheduleId = ScheduledActionIdWithVersion.from(actionId); const existingResult = await this.getScheduledAction.execute(scheduleId); @@ -57,12 +62,13 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { if (error.code === "Scheduler/ScheduledAction/NotFound") { return this.createSchedule( scheduleId, - namespace, - actionType, - targetId, - input, + params.title, + params.namespace, + params.actionType, + params.targetId, + params.input, identity, - payload + params.payload ); } @@ -74,7 +80,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { // Reschedule existing action const scheduledAction = existingResult.value; - return this.reschedule(scheduledAction, input, identity, payload); + return this.reschedule(scheduledAction, params.input, identity, params.payload); } /** @@ -82,6 +88,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { */ private async createSchedule( id: string, + title: string, namespace: string, actionType: string, targetId: string, @@ -93,6 +100,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { const scheduledAction: IScheduledAction = { id: scheduleId, + title, namespace, actionType, targetId, @@ -102,15 +110,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { }; // Create CMS entry - const createResult = await this.createEntryUseCase.execute(this.model, { - id: scheduleId, - namespace, - actionType, - targetId, - scheduledBy: identity, - scheduledOn: input.scheduleOn.toISOString(), - payload - }); + const createResult = await this.createEntryUseCase.execute(this.model, scheduledAction); if (createResult.isFail()) { return Result.fail( @@ -122,15 +122,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.schedulerService.create({ id: scheduleId, - scheduleOn: input.scheduleOn, - payload: { - ScheduledAction: { - id: scheduleId, - namespace, - actionType, - targetId - } - } + scheduleOn: input.scheduleOn }); } catch (error) { // Rollback - delete CMS entry if EventBridge fails @@ -156,6 +148,11 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { identity: Identity, payload?: any ): Promise> { + // Make sure we don't unset the existing payload. + if (!payload && existing.payload) { + payload = existing.payload; + } + // Update CMS entry const existingId = ScheduledActionIdWithVersion.from(existing.id); const updateResult = await this.updateEntryUseCase.execute(this.model, existingId, { @@ -174,15 +171,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.schedulerService.update({ id: existingId, - scheduleOn: input.scheduleOn, - payload: { - ScheduledAction: { - id: existingId, - namespace: existing.namespace, - actionType: existing.actionType, - targetId: existing.targetId - } - } + scheduleOn: input.scheduleOn }); } catch (error) { return Result.fail(new SchedulerServiceError(error as Error)); diff --git a/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts b/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts index 5905e7e4e2d..62727499093 100644 --- a/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts +++ b/packages/api-scheduler/src/features/ScheduleAction/abstractions.ts @@ -23,21 +23,24 @@ export interface IScheduleActionErrors { type ScheduleActionError = IScheduleActionErrors[keyof IScheduleActionErrors]; +interface IScheduleActionParams { + namespace: string; + actionType: string; + targetId: string; + input: ISchedulerInput; + title: string; + payload?: any; +} + export interface IScheduleActionUseCase { - execute( - namespace: string, - actionType: string, - targetId: string, - input: ISchedulerInput, - payload?: any - ): Promise>; + execute(params: IScheduleActionParams): Promise>; } -export const ScheduleActionUseCase = createAbstraction( - "ScheduleActionUseCase" -); +export const ScheduleActionUseCase = + createAbstraction("ScheduleActionUseCase"); export namespace ScheduleActionUseCase { export type Interface = IScheduleActionUseCase; + export type Params = IScheduleActionParams; export type Error = ScheduleActionError; } diff --git a/packages/api-scheduler/src/features/SchedulerFeature.ts b/packages/api-scheduler/src/features/SchedulerFeature.ts index 2bd2dbdd073..3f57ba6a26c 100644 --- a/packages/api-scheduler/src/features/SchedulerFeature.ts +++ b/packages/api-scheduler/src/features/SchedulerFeature.ts @@ -4,6 +4,7 @@ import { GetScheduledActionFeature } from "./GetScheduledAction/feature.js"; import { ListScheduledActionsFeature } from "./ListScheduledActions/feature.js"; import { CancelScheduledActionFeature } from "./CancelScheduledAction/feature.js"; import { ExecuteScheduledActionFeature } from "./ExecuteScheduledAction/feature.js"; +import { RunActionFeature } from "~/features/RunAction/feature.js"; /** * Main Scheduler Feature @@ -21,5 +22,6 @@ export const SchedulerFeature = createFeature({ ListScheduledActionsFeature.register(container); CancelScheduledActionFeature.register(container); ExecuteScheduledActionFeature.register(container); + RunActionFeature.register(container); } }); diff --git a/packages/api-scheduler/src/index.ts b/packages/api-scheduler/src/index.ts index 6e0f1c6d8ad..bd3d6fa50d3 100644 --- a/packages/api-scheduler/src/index.ts +++ b/packages/api-scheduler/src/index.ts @@ -1,9 +1,13 @@ -// Shared abstractions -export * from "./shared/abstractions.js"; -export * from "./domain/errors.js"; +export { + SchedulerService, + ScheduledActionModel, + ScheduledActionHandler +} from "./shared/abstractions.js"; -// Main feature -export { SchedulerFeature } from "./SchedulerFeature.js"; +export type { IScheduledAction, ISchedulerInput } from "./shared/abstractions.js"; + +export { ScheduledActionId } from "./domain/ScheduledActionId.js"; +export { createScheduler } from "./createScheduler.js"; // Feature abstractions (for use case dependencies) export * from "./features/ScheduleAction/index.js"; @@ -11,3 +15,4 @@ export * from "./features/GetScheduledAction/index.js"; export * from "./features/ListScheduledActions/index.js"; export * from "./features/CancelScheduledAction/index.js"; export * from "./features/ExecuteScheduledAction/index.js"; +export * from "./features/RunAction/index.js"; diff --git a/packages/api-scheduler/src/shared/abstractions.ts b/packages/api-scheduler/src/shared/abstractions.ts index 7d6926c5750..01a58c1463e 100644 --- a/packages/api-scheduler/src/shared/abstractions.ts +++ b/packages/api-scheduler/src/shared/abstractions.ts @@ -20,6 +20,7 @@ export interface IScheduledAction { targetId: string; // Resource identifier (entry ID, email ID, etc.) scheduledBy: Identity; scheduledOn: Date; + title?: string; payload?: any; // Action-specific data error?: string; // Error if execution failed } @@ -65,8 +66,8 @@ export namespace ScheduledActionHandler { * Abstracts the underlying scheduling infrastructure (AWS EventBridge, Azure Logic Apps, etc.) */ export interface ISchedulerService { - create(params: { id: string; scheduleOn: Date; payload?: any }): Promise; - update(params: { id: string; scheduleOn: Date; payload?: any }): Promise; + create(params: { id: string; scheduleOn: Date }): Promise; + update(params: { id: string; scheduleOn: Date }): Promise; delete(id: string): Promise; exists(id: string): Promise; } diff --git a/packages/api-scheduler/tsconfig.build.json b/packages/api-scheduler/tsconfig.build.json index 316612c4386..b95adf1c7ec 100644 --- a/packages/api-scheduler/tsconfig.build.json +++ b/packages/api-scheduler/tsconfig.build.json @@ -11,6 +11,7 @@ { "path": "../plugins/tsconfig.build.json" }, { "path": "../pubsub/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, + { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../wcp/tsconfig.build.json" } ], @@ -165,6 +166,8 @@ "@webiny/pubsub": ["../pubsub/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], "@webiny/wcp/*": ["../wcp/src/*"], diff --git a/packages/api-scheduler/tsconfig.json b/packages/api-scheduler/tsconfig.json index 1da8bb089a0..cc2ac6056c7 100644 --- a/packages/api-scheduler/tsconfig.json +++ b/packages/api-scheduler/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../plugins" }, { "path": "../pubsub" }, { "path": "../db-dynamodb" }, + { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../wcp" } ], @@ -165,6 +166,8 @@ "@webiny/pubsub": ["../pubsub/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], + "@webiny/handler/*": ["../handler/src/*"], + "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], "@webiny/handler-aws": ["../handler-aws/src"], "@webiny/wcp/*": ["../wcp/src/*"], diff --git a/packages/app-headless-cms-scheduler/src/Gateways/SchedulerListGateway.ts b/packages/app-headless-cms-scheduler/src/Gateways/SchedulerListGateway.ts index 9e673ca0dba..9abed2e468f 100644 --- a/packages/app-headless-cms-scheduler/src/Gateways/SchedulerListGateway.ts +++ b/packages/app-headless-cms-scheduler/src/Gateways/SchedulerListGateway.ts @@ -6,7 +6,6 @@ export interface ISchedulerListExecuteParamsWhere { targetId?: string; title_contains?: string; title_not_contains?: string; - targetEntryId?: string; type?: ScheduleType; scheduledBy?: string; scheduledOn?: Date; From 755b7f32dda45f8f03ae15430895c171baffdbc3 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 21:48:48 +0100 Subject: [PATCH 41/71] wip: implement lifecycle hooks via event handlers --- .../api-headless-cms-scheduler/src/context.ts | 9 +-- .../CancelScheduledActionOnDeleteHandler.ts | 37 ++++++++++++ .../CancelScheduledActionOnPublishHandler.ts | 37 ++++++++++++ ...CancelScheduledActionOnUnpublishHandler.ts | 37 ++++++++++++ .../feature.ts | 20 +++++++ .../src/hooks/index.ts | 56 ------------------- 6 files changed, 133 insertions(+), 63 deletions(-) create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts create mode 100644 packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/feature.ts delete mode 100644 packages/api-headless-cms-scheduler/src/hooks/index.ts diff --git a/packages/api-headless-cms-scheduler/src/context.ts b/packages/api-headless-cms-scheduler/src/context.ts index 8cb3c6c25b9..4a9f605171a 100644 --- a/packages/api-headless-cms-scheduler/src/context.ts +++ b/packages/api-headless-cms-scheduler/src/context.ts @@ -1,17 +1,12 @@ -// import { attachLifecycleHooks } from "~/hooks/index.js"; - import { ContextPlugin } from "@webiny/api"; import { ScheduleEntryActionFeature } from "~/features/ScheduleEntryAction/feature.js"; import { CancelScheduledEntryActionFeature } from "~/features/CancelScheduledEntryAction/feature.js"; +import { CancelScheduledActionOnEntryChangeFeature } from "~/features/CancelScheduledActionOnEntryChange/feature.js"; export const createHeadlessCmsScheduleContext = () => { return new ContextPlugin(async context => { ScheduleEntryActionFeature.register(context.container); CancelScheduledEntryActionFeature.register(context.container); - - // TODO: refactor to event handlers - // attachLifecycleHooks({ - // cms: context.cms - // }); + CancelScheduledActionOnEntryChangeFeature.register(context.container); }); }; diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts new file mode 100644 index 00000000000..5159629067c --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts @@ -0,0 +1,37 @@ +import { EntryAfterDeleteHandler } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/events"; +import { CancelScheduledEntryActionUseCase } from "../CancelScheduledEntryAction/index.js"; + +/** + * Cancels scheduled actions when an entry is deleted + * + * When a user deletes an entry, any scheduled publish/unpublish + * actions for that entry should be cancelled since the entry + * no longer exists. + */ +class CancelScheduledActionOnDeleteHandlerImpl implements EntryAfterDeleteHandler.Interface { + constructor(private cancelScheduledEntryAction: CancelScheduledEntryActionUseCase.Interface) {} + + async handle(event: EntryAfterDeleteHandler.Event): Promise { + const { entry, model } = event.payload; + + // Skip private models + if (model.isPrivate) { + return; + } + + try { + await this.cancelScheduledEntryAction.execute({ + modelId: model.modelId, + targetId: entry.id + }); + } catch (error) { + // Silently ignore errors - this is non-critical cleanup + // The entry was deleted successfully, cancelling scheduled actions is best-effort + } + } +} + +export const CancelScheduledActionOnDeleteHandler = EntryAfterDeleteHandler.createImplementation({ + implementation: CancelScheduledActionOnDeleteHandlerImpl, + dependencies: [CancelScheduledEntryActionUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts new file mode 100644 index 00000000000..654984a1226 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts @@ -0,0 +1,37 @@ +import { EntryAfterPublishHandler } from "@webiny/api-headless-cms/features/contentEntry/PublishEntry/events"; +import { CancelScheduledEntryActionUseCase } from "../CancelScheduledEntryAction/index.js"; + +/** + * Cancels scheduled actions when an entry is manually published + * + * When a user manually publishes an entry, any scheduled publish/unpublish + * actions for that entry should be cancelled since the manual action + * takes precedence. + */ +class CancelScheduledActionOnPublishHandlerImpl implements EntryAfterPublishHandler.Interface { + constructor(private cancelScheduledEntryAction: CancelScheduledEntryActionUseCase.Interface) {} + + async handle(event: EntryAfterPublishHandler.Event): Promise { + const { entry, model } = event.payload; + + // Skip private models + if (model.isPrivate) { + return; + } + + try { + await this.cancelScheduledEntryAction.execute({ + modelId: model.modelId, + targetId: entry.id + }); + } catch (error) { + // Silently ignore errors - this is non-critical cleanup + // The entry was published successfully, cancelling scheduled actions is best-effort + } + } +} + +export const CancelScheduledActionOnPublishHandler = EntryAfterPublishHandler.createImplementation({ + implementation: CancelScheduledActionOnPublishHandlerImpl, + dependencies: [CancelScheduledEntryActionUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts new file mode 100644 index 00000000000..4b6780ee347 --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts @@ -0,0 +1,37 @@ +import { EntryAfterUnpublishHandler } from "@webiny/api-headless-cms/features/contentEntry/UnpublishEntry/events"; +import { CancelScheduledEntryActionUseCase } from "../CancelScheduledEntryAction/index.js"; + +/** + * Cancels scheduled actions when an entry is manually unpublished + * + * When a user manually unpublishes an entry, any scheduled publish/unpublish + * actions for that entry should be cancelled since the manual action + * takes precedence. + */ +class CancelScheduledActionOnUnpublishHandlerImpl implements EntryAfterUnpublishHandler.Interface { + constructor(private cancelScheduledEntryAction: CancelScheduledEntryActionUseCase.Interface) {} + + async handle(event: EntryAfterUnpublishHandler.Event): Promise { + const { entry, model } = event.payload; + + // Skip private models + if (model.isPrivate) { + return; + } + + try { + await this.cancelScheduledEntryAction.execute({ + modelId: model.modelId, + targetId: entry.id + }); + } catch (error) { + // Silently ignore errors - this is non-critical cleanup + // The entry was unpublished successfully, cancelling scheduled actions is best-effort + } + } +} + +export const CancelScheduledActionOnUnpublishHandler = EntryAfterUnpublishHandler.createImplementation({ + implementation: CancelScheduledActionOnUnpublishHandlerImpl, + dependencies: [CancelScheduledEntryActionUseCase] +}); diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/feature.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/feature.ts new file mode 100644 index 00000000000..d3ad74e5c0b --- /dev/null +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/feature.ts @@ -0,0 +1,20 @@ +import { createFeature } from "@webiny/feature/api"; +import { CancelScheduledActionOnPublishHandler } from "./CancelScheduledActionOnPublishHandler.js"; +import { CancelScheduledActionOnUnpublishHandler } from "./CancelScheduledActionOnUnpublishHandler.js"; +import { CancelScheduledActionOnDeleteHandler } from "./CancelScheduledActionOnDeleteHandler.js"; + +/** + * CancelScheduledActionOnEntryChange Feature + * + * Automatically cancels scheduled actions when entries are manually + * published, unpublished, or deleted. This ensures scheduled actions + * don't execute after a user has already performed the action manually. + */ +export const CancelScheduledActionOnEntryChangeFeature = createFeature({ + name: "CancelScheduledActionOnEntryChange", + register(container) { + container.register(CancelScheduledActionOnPublishHandler); + container.register(CancelScheduledActionOnUnpublishHandler); + container.register(CancelScheduledActionOnDeleteHandler); + } +}); diff --git a/packages/api-headless-cms-scheduler/src/hooks/index.ts b/packages/api-headless-cms-scheduler/src/hooks/index.ts deleted file mode 100644 index 2b345efb43b..00000000000 --- a/packages/api-headless-cms-scheduler/src/hooks/index.ts +++ /dev/null @@ -1,56 +0,0 @@ -// @ts-nocheck TODO migrate lifecycle events -/** - * We are attaching hooks so we can make sure that the scheduled jobs are removed. - * This is due the possibility that the user publishes/unpublishes/deletes entry which is scheduled. - * At that point, there is no need to run the scheduled job as the user already did the action manually. - */ -import type { CmsContext, CmsEntry, CmsModel } from "@webiny/api-headless-cms/types/index.js"; -import { SCHEDULE_MODEL_ID } from "~/constants.js"; - -export interface IAttachLifecycleHookParams { - cms: CmsContext["cms"]; -} - -export const attachLifecycleHooks = (params: IAttachLifecycleHookParams): void => { - const { cms } = params; - - const shouldContinue = (model: Pick): boolean => { - if (model.modelId === SCHEDULE_MODEL_ID) { - return false; - } - - // TODO maybe change with a list of private models which are allowed? - else if (model.isPrivate) { - return false; - } - - return true; - }; - - const cancel = async (model: CmsModel, target: Pick): Promise => { - if (shouldContinue(model) === false) { - return; - } - const scheduler = cms.scheduler(model); - - const entry = await scheduler.getScheduled(target.id); - if (!entry) { - return; - } - try { - await scheduler.cancel(entry.id); - } catch { - // does not matter - } - }; - - cms.onEntryAfterPublish.subscribe(async ({ entry, model }) => { - return cancel(model, entry); - }); - cms.onEntryAfterUnpublish.subscribe(async ({ entry, model }) => { - return cancel(model, entry); - }); - cms.onEntryAfterDelete.subscribe(async ({ entry, model }) => { - return cancel(model, entry); - }); -}; From 5f56017175b06def88f289216fc56c4bce1b05e1 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 22:04:25 +0100 Subject: [PATCH 42/71] wip: update test --- .../__tests__/utils/modelFieldTraverser.test.ts | 14 +++++++++++++- .../contentModelGroup/shared/GroupCache.ts | 1 - 2 files changed, 13 insertions(+), 2 deletions(-) delete mode 100644 packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts diff --git a/packages/api-headless-cms/__tests__/utils/modelFieldTraverser.test.ts b/packages/api-headless-cms/__tests__/utils/modelFieldTraverser.test.ts index cab87335047..7b3dfabaafa 100644 --- a/packages/api-headless-cms/__tests__/utils/modelFieldTraverser.test.ts +++ b/packages/api-headless-cms/__tests__/utils/modelFieldTraverser.test.ts @@ -77,7 +77,7 @@ describe("model field traverser", () => { }); // TODO: update the test - it.skip("should properly traverse through model fields - page builder", async () => { + it("should properly traverse through model fields - page builder", async () => { const model = await context.cms.getModel(pageModel.modelId); const ast = converter.toAst(model); const traverser = new ModelFieldTraverser(); @@ -97,6 +97,18 @@ describe("model field traverser", () => { }); expect(result.sort()).toEqual([ + "datetime@content.content.date#s", + "datetime@content.content.dateTimeWithTimezone#s", + "datetime@content.content.dateTimeWithoutTimezone#s", + "datetime@content.content.nestedObject.objectNestedObject.date#s", + "datetime@content.content.nestedObject.objectNestedObject.dateTimeWithTimezone#s", + "datetime@content.content.nestedObject.objectNestedObject.dateTimeWithoutTimezone#s", + "datetime@content.content.nestedObject.objectNestedObject.time#s", + "datetime@content.content.time#s", + "datetime@objective.objective.nestedObject.objectNestedObject.date#s", + "datetime@objective.objective.nestedObject.objectNestedObject.dateTimeWithTimezone#s", + "datetime@objective.objective.nestedObject.objectNestedObject.dateTimeWithoutTimezone#s", + "datetime@objective.objective.nestedObject.objectNestedObject.time#s", "dynamicZone@content#m", "dynamicZone@content.content#m", "dynamicZone@content.content#m", diff --git a/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts b/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts deleted file mode 100644 index 9daeafb9864..00000000000 --- a/packages/api-headless-cms/packages/api-headless-cms/src/features/contentModelGroup/shared/GroupCache.ts +++ /dev/null @@ -1 +0,0 @@ -test From 2a23ff46a81ff1b9704d249e33c4d5419b015108 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 22:28:12 +0100 Subject: [PATCH 43/71] wip: update ai context and websockets --- ai-context/backend-developer-guide.md | 4 +- ai-context/core-features-reference.md | 93 +++++++++++++++++++ packages/api-websockets/package.json | 5 + packages/api-websockets/src/context/index.ts | 8 +- .../WebsocketsContext/abstractions.ts | 8 ++ .../src/features/WebsocketsContext/index.ts | 1 + packages/api-websockets/src/index.ts | 1 + 7 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 packages/api-websockets/src/features/WebsocketsContext/abstractions.ts create mode 100644 packages/api-websockets/src/features/WebsocketsContext/index.ts diff --git a/ai-context/backend-developer-guide.md b/ai-context/backend-developer-guide.md index 157126fbbbf..39df85b3f09 100644 --- a/ai-context/backend-developer-guide.md +++ b/ai-context/backend-developer-guide.md @@ -350,12 +350,10 @@ This pattern provides: ### Use Case Error Handling Pattern -**CRITICAL:** Use cases MUST extend repository errors with their own use-case-specific errors. - Every use case abstraction must define: 1. An extendable error interface for use-case-specific errors -2. A union type combining use-case errors with repository errors +2. A union type combining domain errors that can be returned from the use case 3. An exported error type in the namespace ```typescript diff --git a/ai-context/core-features-reference.md b/ai-context/core-features-reference.md index 6293ea564ee..bca0920f02a 100644 --- a/ai-context/core-features-reference.md +++ b/ai-context/core-features-reference.md @@ -43,6 +43,99 @@ This document provides the correct import paths and type definitions for commonl --- +## Headless CMS Features + +### Content Entry Features + +#### GetEntryById +- **Import:** `import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/GetEntryById/abstractions.ts` +- **Usage:** Fetch single entry by exact revision ID + +#### GetEntry +- **Import:** `import { GetEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntry"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts` +- **Usage:** Get single entry by query parameters (where + sort) + +#### ListEntries +- **Import:** `import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` +- **Usage:** Base abstraction for listing entries with filtering and pagination + +#### ListLatestEntries +- **Import:** `import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` +- **Usage:** List latest entries (manage API) + +#### ListPublishedEntries +- **Import:** `import { ListPublishedEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` +- **Usage:** List published entries (read API) + +#### ListDeletedEntries +- **Import:** `import { ListDeletedEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` +- **Usage:** List deleted entries (manage API) + +#### CreateEntry +- **Import:** `import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts` +- **Usage:** Create new content entry + +#### UpdateEntry +- **Import:** `import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts` +- **Usage:** Update existing content entry + +#### DeleteEntry +- **Import:** `import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/DeleteEntry/abstractions.ts` +- **Usage:** Delete content entry + +#### ListEntriesRepository +- **Import:** `import { ListEntriesRepository } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` +- **Usage:** Repository for fetching entries from storage + +### Content Model Features + +#### GetModel +- **Import:** `import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts` +- **Usage:** Retrieve single model by ID with access control + +#### ListModels +- **Import:** `import { ListModelsUseCase } from "@webiny/api-headless-cms/features/contentModel/ListModels"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts` +- **Usage:** List all accessible content models + +#### GetModelRepository +- **Import:** `import { GetModelRepository } from "@webiny/api-headless-cms/features/contentModel/GetModel"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/GetModel/abstractions.ts` +- **Usage:** Fetch model from cache (plugin + DB models) + +#### ListModelsRepository +- **Import:** `import { ListModelsRepository } from "@webiny/api-headless-cms/features/contentModel/ListModels"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/ListModels/abstractions.ts` +- **Usage:** Fetch all models from cache + +#### ModelsFetcher +- **Import:** `import { ModelsFetcher } from "@webiny/api-headless-cms/features/contentModel/shared"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts` +- **Usage:** Centralized model fetching with caching and access control + +#### ModelCache +- **Import:** `import { ModelCache } from "@webiny/api-headless-cms/features/contentModel/shared"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts` +- **Usage:** Cache for content models + +#### PluginModelsProvider +- **Import:** `import { PluginModelsProvider } from "@webiny/api-headless-cms/features/contentModel/shared"` +- **Interface Type:** See `packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts` +- **Usage:** Access to plugin-defined models + +--- + ## Notes - Always import abstractions from the feature path (not from package root) diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json index 03aea182359..e604128401e 100644 --- a/packages/api-websockets/package.json +++ b/packages/api-websockets/package.json @@ -11,6 +11,11 @@ "contributors": [ "Bruno Zorić " ], + "exports": { + "./features/WebsocketsContext": "./src/features/WebsocketsContext/index.js", + "./*": "./src/*", + ".": "./src/index.js" + }, "license": "MIT", "dependencies": { "@webiny/api": "0.0.0", diff --git a/packages/api-websockets/src/context/index.ts b/packages/api-websockets/src/context/index.ts index 8331700766a..1efe9a1593c 100644 --- a/packages/api-websockets/src/context/index.ts +++ b/packages/api-websockets/src/context/index.ts @@ -1,10 +1,10 @@ import { ContextPlugin } from "@webiny/handler"; import type { Context } from "~/types.js"; -import { WebsocketsContext } from "./WebsocketsContext.js"; +import { WebsocketsContext as WebsocketsImplementation } from "./WebsocketsContext.js"; import { WebsocketsConnectionRegistry } from "~/registry/index.js"; import { WebsocketsTransport } from "~/transport/index.js"; +import { WebsocketsContext } from "~/features/WebsocketsContext/abstractions.js"; -export * from "./WebsocketsContext.js"; export type * from "./abstractions/IWebsocketsContext.js"; export const createWebsocketsContext = () => { @@ -16,7 +16,9 @@ export const createWebsocketsContext = () => { const documentClient = context.db.driver.documentClient; const registry = new WebsocketsConnectionRegistry(documentClient); const transport = new WebsocketsTransport(); - context.websockets = new WebsocketsContext(registry, transport); + context.websockets = new WebsocketsImplementation(registry, transport); + + context.container.registerInstance(WebsocketsContext, context.websockets); }); plugin.name = "websockets.context"; diff --git a/packages/api-websockets/src/features/WebsocketsContext/abstractions.ts b/packages/api-websockets/src/features/WebsocketsContext/abstractions.ts new file mode 100644 index 00000000000..c1c0797735d --- /dev/null +++ b/packages/api-websockets/src/features/WebsocketsContext/abstractions.ts @@ -0,0 +1,8 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { IWebsocketsContextObject } from "~/context/abstractions/IWebsocketsContext.js"; + +export const WebsocketsContext = createAbstraction("WebsocketsContext"); + +export namespace WebsocketsContext { + export type Interface = IWebsocketsContextObject; +} diff --git a/packages/api-websockets/src/features/WebsocketsContext/index.ts b/packages/api-websockets/src/features/WebsocketsContext/index.ts new file mode 100644 index 00000000000..3e217c2a8e9 --- /dev/null +++ b/packages/api-websockets/src/features/WebsocketsContext/index.ts @@ -0,0 +1 @@ +export { WebsocketsContext } from "./abstractions.js"; diff --git a/packages/api-websockets/src/index.ts b/packages/api-websockets/src/index.ts index 4e22679703c..cc3a266c84d 100644 --- a/packages/api-websockets/src/index.ts +++ b/packages/api-websockets/src/index.ts @@ -14,4 +14,5 @@ export * from "./registry/index.js"; export * from "./context/index.js"; export * from "./plugins/index.js"; +export * from "./abstractions.js"; export type * from "./types.js"; From fc6a80e9cfa667ad1925129d52557d26b9773c88 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 19 Nov 2025 22:39:24 +0100 Subject: [PATCH 44/71] wip: migrate record locking --- .../src/domain/LockRecord.ts | 145 ++++++++++++++++++ .../api-record-locking/src/domain/errors.ts | 84 ++++++++++ .../api-record-locking/src/domain/types.ts | 55 +++++++ 3 files changed, 284 insertions(+) create mode 100644 packages/api-record-locking/src/domain/LockRecord.ts create mode 100644 packages/api-record-locking/src/domain/errors.ts create mode 100644 packages/api-record-locking/src/domain/types.ts diff --git a/packages/api-record-locking/src/domain/LockRecord.ts b/packages/api-record-locking/src/domain/LockRecord.ts new file mode 100644 index 00000000000..b44d048379e --- /dev/null +++ b/packages/api-record-locking/src/domain/LockRecord.ts @@ -0,0 +1,145 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { CmsIdentity } from "@webiny/api-headless-cms/types"; +import { RecordLockingLockRecordActionType } from "./types.js"; +import type { + LockRecordAction, + LockRecordApprovedAction, + LockRecordDeniedAction, + LockRecordEntryType, + LockRecordObject, + LockRecordRequestedAction, + LockRecordValues +} from "./types.js"; +import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId.js"; +import { calculateExpiresOn } from "~/utils/calculateExpiresOn.js"; + +export interface ILockRecord { + readonly id: string; + readonly targetId: string; + readonly type: LockRecordEntryType; + readonly lockedBy: CmsIdentity; + readonly lockedOn: Date; + readonly updatedOn: Date; + readonly expiresOn: Date; + readonly actions?: LockRecordAction[]; + + toObject(): LockRecordObject; + addAction(action: LockRecordAction): void; + getUnlockRequested(): LockRecordRequestedAction | undefined; + getUnlockApproved(): LockRecordApprovedAction | undefined; + getUnlockDenied(): LockRecordDeniedAction | undefined; + isExpired(): boolean; +} + +export type LockRecordParams = Pick< + CmsEntry, + "entryId" | "values" | "createdBy" | "createdOn" | "savedOn" +>; + +export class LockRecord implements ILockRecord { + private readonly _id: string; + private readonly _targetId: string; + private readonly _type: LockRecordEntryType; + private readonly _lockedBy: CmsIdentity; + private readonly _lockedOn: Date; + private readonly _updatedOn: Date; + private readonly _expiresOn: Date; + private _actions?: LockRecordAction[]; + + public get id(): string { + return this._id; + } + + public get targetId(): string { + return this._targetId; + } + + public get type(): LockRecordEntryType { + return this._type; + } + + public get lockedBy(): CmsIdentity { + return this._lockedBy; + } + + public get lockedOn(): Date { + return this._lockedOn; + } + + public get updatedOn(): Date { + return this._updatedOn; + } + + public get expiresOn(): Date { + return this._expiresOn; + } + + public get actions(): LockRecordAction[] | undefined { + return this._actions; + } + + public constructor(input: LockRecordParams, timeout: number) { + this._id = removeLockRecordDatabasePrefix(input.entryId); + this._targetId = input.values.targetId; + this._type = input.values.type; + this._lockedBy = input.createdBy; + this._lockedOn = new Date(input.createdOn); + this._updatedOn = new Date(input.savedOn); + this._expiresOn = calculateExpiresOn(input, timeout); + this._actions = input.values.actions; + } + + public toObject(): LockRecordObject { + return { + id: this._id, + targetId: this._targetId, + type: this._type, + lockedBy: this._lockedBy, + lockedOn: this._lockedOn, + updatedOn: this._updatedOn, + expiresOn: this._expiresOn, + actions: this._actions + }; + } + + public addAction(action: LockRecordAction): void { + if (!this._actions) { + this._actions = []; + } + this._actions.push(action); + } + + public getUnlockRequested(): LockRecordRequestedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is LockRecordRequestedAction => + action.type === RecordLockingLockRecordActionType.requested + ); + } + + public getUnlockApproved(): LockRecordApprovedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is LockRecordApprovedAction => + action.type === RecordLockingLockRecordActionType.approved + ); + } + + public getUnlockDenied(): LockRecordDeniedAction | undefined { + if (!this._actions?.length) { + return undefined; + } + return this._actions.find( + (action): action is LockRecordDeniedAction => + action.type === RecordLockingLockRecordActionType.denied + ); + } + + public isExpired(): boolean { + return this._expiresOn.getTime() < new Date().getTime(); + } +} diff --git a/packages/api-record-locking/src/domain/errors.ts b/packages/api-record-locking/src/domain/errors.ts new file mode 100644 index 00000000000..4296a440166 --- /dev/null +++ b/packages/api-record-locking/src/domain/errors.ts @@ -0,0 +1,84 @@ +import { BaseError } from "@webiny/feature/api"; + +export class EntryAlreadyLockedError extends BaseError<{ id: string; type: string }> { + override readonly code = "RecordLocking/Entry/AlreadyLockedError" as const; + + constructor(data: { id: string; type: string }) { + super({ + message: "Entry is already locked for editing.", + data + }); + } +} + +export class LockRecordNotFoundError extends BaseError<{ id: string }> { + override readonly code = "RecordLocking/LockRecord/NotFoundError" as const; + + constructor(data: { id: string }) { + super({ + message: "Lock Record not found.", + data + }); + } +} + +export class LockRecordPersistenceError extends BaseError { + override readonly code = "RecordLocking/LockRecord/PersistenceError" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class NotSameIdentityError extends BaseError<{ currentId: string; targetId: string }> { + override readonly code = "RecordLocking/Identity/NotSameError" as const; + + constructor(data: { currentId: string; targetId: string }) { + super({ + message: "Identity mismatch - cannot perform action.", + data + }); + } +} + +export class UnlockEntryError extends BaseError { + override readonly code = "RecordLocking/Entry/UnlockError" as const; + + constructor(error: Error) { + super({ + message: `Could not unlock entry: ${error.message}` + }); + } +} + +export class LockEntryError extends BaseError { + override readonly code = "RecordLocking/Entry/LockError" as const; + + constructor(error: Error) { + super({ + message: `Could not lock entry: ${error.message}` + }); + } +} + +export class UpdateEntryLockError extends BaseError { + override readonly code = "RecordLocking/Entry/UpdateLockError" as const; + + constructor(error: Error) { + super({ + message: `Could not update entry lock: ${error.message}` + }); + } +} + +export class IdentityMissingError extends BaseError { + override readonly code = "RecordLocking/Identity/MissingError" as const; + + constructor() { + super({ + message: "Identity is missing." + }); + } +} diff --git a/packages/api-record-locking/src/domain/types.ts b/packages/api-record-locking/src/domain/types.ts new file mode 100644 index 00000000000..6941769c29b --- /dev/null +++ b/packages/api-record-locking/src/domain/types.ts @@ -0,0 +1,55 @@ +import type { CmsIdentity } from "@webiny/api-headless-cms/types"; + +/** + * Do not use any special chars other than #, as we use this to create lock record IDs. + */ +export type LockRecordEntryType = string; + +export enum RecordLockingLockRecordActionType { + requested = "requested", + approved = "approved", + denied = "denied" +} + +export interface LockRecordRequestedAction { + type: RecordLockingLockRecordActionType.requested; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export interface LockRecordApprovedAction { + type: RecordLockingLockRecordActionType.approved; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export interface LockRecordDeniedAction { + type: RecordLockingLockRecordActionType.denied; + message?: string; + createdOn: Date; + createdBy: CmsIdentity; +} + +export type LockRecordAction = + | LockRecordRequestedAction + | LockRecordApprovedAction + | LockRecordDeniedAction; + +export interface LockRecordValues { + targetId: string; + type: LockRecordEntryType; + actions?: LockRecordAction[]; +} + +export interface LockRecordObject { + id: string; + targetId: string; + type: LockRecordEntryType; + lockedBy: CmsIdentity; + lockedOn: Date; + updatedOn: Date; + expiresOn: Date; + actions?: LockRecordAction[]; +} From f0f0e48ac30c105efd6a070002853b38f1bf9697 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 00:15:40 +0100 Subject: [PATCH 45/71] wip: migrate record locking --- packages/api-record-locking/MIGRATION_PLAN.md | 530 ++++++++++++++++++ .../src/domain/abstractions.ts | 24 + .../api-record-locking/src/domain/index.ts | 4 + .../api-record-locking/src/domain/model.ts | 150 +++++ .../GetLockRecord/GetLockRecordRepository.ts | 45 ++ .../GetLockRecord/GetLockRecordUseCase.ts | 22 + .../features/GetLockRecord/abstractions.ts | 55 ++ .../src/features/GetLockRecord/feature.ts | 11 + .../src/features/GetLockRecord/index.ts | 2 + .../IsEntryLocked/IsEntryLockedUseCase.ts | 46 ++ .../features/IsEntryLocked/abstractions.ts | 31 + .../src/features/IsEntryLocked/feature.ts | 9 + .../src/features/IsEntryLocked/index.ts | 1 + .../KickOutCurrentUserUseCase.ts | 48 ++ .../KickOutCurrentUser/abstractions.ts | 25 + .../features/KickOutCurrentUser/feature.ts | 9 + .../src/features/KickOutCurrentUser/index.ts | 1 + .../ListLockRecordsRepository.ts | 53 ++ .../ListLockRecords/ListLockRecordsUseCase.ts | 38 ++ .../features/ListLockRecords/abstractions.ts | 53 ++ .../src/features/ListLockRecords/feature.ts | 11 + .../src/features/ListLockRecords/index.ts | 1 + packages/api-record-locking/src/index.ts | 25 +- 23 files changed, 1187 insertions(+), 7 deletions(-) create mode 100644 packages/api-record-locking/MIGRATION_PLAN.md create mode 100644 packages/api-record-locking/src/domain/abstractions.ts create mode 100644 packages/api-record-locking/src/domain/index.ts create mode 100644 packages/api-record-locking/src/domain/model.ts create mode 100644 packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts create mode 100644 packages/api-record-locking/src/features/GetLockRecord/GetLockRecordUseCase.ts create mode 100644 packages/api-record-locking/src/features/GetLockRecord/abstractions.ts create mode 100644 packages/api-record-locking/src/features/GetLockRecord/feature.ts create mode 100644 packages/api-record-locking/src/features/GetLockRecord/index.ts create mode 100644 packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts create mode 100644 packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts create mode 100644 packages/api-record-locking/src/features/IsEntryLocked/feature.ts create mode 100644 packages/api-record-locking/src/features/IsEntryLocked/index.ts create mode 100644 packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts create mode 100644 packages/api-record-locking/src/features/KickOutCurrentUser/abstractions.ts create mode 100644 packages/api-record-locking/src/features/KickOutCurrentUser/feature.ts create mode 100644 packages/api-record-locking/src/features/KickOutCurrentUser/index.ts create mode 100644 packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsRepository.ts create mode 100644 packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts create mode 100644 packages/api-record-locking/src/features/ListLockRecords/abstractions.ts create mode 100644 packages/api-record-locking/src/features/ListLockRecords/feature.ts create mode 100644 packages/api-record-locking/src/features/ListLockRecords/index.ts diff --git a/packages/api-record-locking/MIGRATION_PLAN.md b/packages/api-record-locking/MIGRATION_PLAN.md new file mode 100644 index 00000000000..7f049447298 --- /dev/null +++ b/packages/api-record-locking/MIGRATION_PLAN.md @@ -0,0 +1,530 @@ +# Migration Plan: api-record-locking → Feature-Based Architecture + +## Current Architecture Issues + +1. **`getManager()` pattern**: Uses async function that returns entry manager - should inject use cases directly +2. **No abstractions**: Use cases created imperatively without DI abstractions +3. **No events**: Uses pubsub topics instead of EventPublisher pattern +4. **Direct CMS dependencies**: Directly calls `context.cms.getModel()` and `context.cms.getEntryManager()` +5. **Mixed concerns**: CRUD factory mixes setup logic with business logic + +## Migration Strategy + +### Phase 1: Create Feature Structure + +``` +packages/api-record-locking/src/features/ +├── shared/ +│ ├── abstractions.ts # Shared types, domain model interfaces +│ ├── errors.ts # Domain errors +│ └── LockRecord.ts # Domain model +├── LockEntry/ +│ ├── abstractions.ts +│ ├── events.ts +│ ├── LockEntryUseCase.ts +│ ├── LockEntryRepository.ts # Repository for this use case only +│ ├── feature.ts +│ └── types.ts +├── UnlockEntry/ +│ ├── abstractions.ts +│ ├── events.ts +│ ├── UnlockEntryUseCase.ts +│ ├── UnlockEntryRepository.ts # Repository for this use case only +│ ├── feature.ts +│ └── types.ts +├── GetLockRecord/ +│ ├── abstractions.ts +│ ├── GetLockRecordUseCase.ts +│ ├── GetLockRecordRepository.ts # Repository for this use case only +│ └── feature.ts +├── ListLockRecords/ +│ ├── abstractions.ts +│ ├── ListLockRecordsUseCase.ts +│ ├── ListLockRecordsRepository.ts # Repository for this use case only +│ └── feature.ts +├── UpdateEntryLock/ +│ ├── abstractions.ts +│ ├── UpdateEntryLockUseCase.ts +│ ├── UpdateEntryLockRepository.ts # Repository for this use case only +│ └── feature.ts +└── RecordLockingManagement/ + └── feature.ts # Composite feature +``` + +### Phase 2: Replace `getManager()` with Proper Dependencies + +**Current Pattern (Bad):** + +```typescript +getManager(): Promise +``` + +**New Pattern (Good):** + +```typescript +// Inject proper use cases from cms package +constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private createEntry: CreateEntryUseCase.Interface, + private deleteEntry: DeleteEntryUseCase.Interface, + private getModel: GetModelUseCase.Interface +) +``` + +### Phase 3: Convert PubSub Topics to EventPublisher + +**Current (Bad):** + +```typescript +const onEntryBeforeLock = createTopic(); +const onEntryAfterLock = createTopic(); +await onEntryBeforeLock.publish(params); +``` + +**New (Good):** + +```typescript +// events.ts +export class EntryBeforeLockEvent extends DomainEvent { + eventType = "RecordLocking/Entry/BeforeLock" as const; + getHandlerAbstraction() { return EntryBeforeLockHandler; } +} + +export const EntryBeforeLockHandler = + createAbstraction>("EntryBeforeLockHandler"); + +export namespace EntryBeforeLockHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeLockEvent; +} + +// UseCase +await this.eventPublisher.publish(new EntryBeforeLockEvent({ id, type })); +``` + +### Phase 4: Use Existing Abstractions for Dependencies + +**Import IdentityContext (replaces SecurityGateway):** + +```typescript +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; + +// In use case constructor +constructor( + private identityContext: IdentityContext.Interface, + // ... other dependencies +) {} + +// Usage in use case +const identity = await this.identityContext.getIdentity(); +``` + +**Import WebsocketsContext:** + +```typescript +import { WebsocketsContext } from "@webiny/api-websockets/features/WebsocketsContext"; + +// In use case constructor +constructor( + private websocketsContext: WebsocketsContext.Interface, + // ... other dependencies +) {} + +// Usage in use case +await this.websocketsContext.send(identity, data); +``` + +**Note:** These abstractions are already registered in their respective packages, so no manual registration needed. + +### Phase 5: Domain Errors + +**Create domain-specific errors:** + +```typescript +// shared/errors.ts +import { BaseError } from "@webiny/feature/api"; + +export class EntryAlreadyLockedError extends BaseError { + override readonly code = "RecordLocking/EntryAlreadyLockedError" as const; + + constructor(data: { id: string; type: string }) { + super({ + message: "Entry is already locked for editing.", + data + }); + } +} + +export class LockRecordNotFoundError extends BaseError { + override readonly code = "RecordLocking/LockRecordNotFoundError" as const; + + constructor(data: { id: string }) { + super({ + message: "Lock Record not found.", + data + }); + } +} + +export class LockRecordPersistenceError extends BaseError { + override readonly code = "RecordLocking/LockRecordPersistenceError" as const; + + constructor(error: Error) { + super({ + message: error.message, + data: {} + }); + } +} + +export class NotSameIdentityError extends BaseError { + override readonly code = "RecordLocking/NotSameIdentityError" as const; + + constructor(data: { currentId: string; targetId: string }) { + super({ + message: "Identity mismatch - cannot perform action.", + data + }); + } +} + +export class UnlockEntryError extends BaseError { + override readonly code = "RecordLocking/UnlockEntryError" as const; + + constructor(error: Error) { + super({ + message: `Could not unlock entry: ${error.message}`, + data: {} + }); + } +} + +export class LockEntryError extends BaseError { + override readonly code = "RecordLocking/LockEntryError" as const; + + constructor(error: Error) { + super({ + message: `Could not lock entry: ${error.message}`, + data: {} + }); + } +} +``` + +### Phase 6: Key Use Cases to Migrate + +#### 1. LockEntry - Lock an entry for editing + +**Dependencies:** +- `LockEntryRepository` - Internal repository (injected into use case) +- `IsEntryLockedUseCase` - Internal use case +- `EventPublisher` - From `@webiny/api-core/features/EventPublisher` +- `IdentityContext` - From `@webiny/api-core/features/IdentityContext` + +**Repository Dependencies (LockEntryRepository):** +- `GetEntryByIdUseCase` - From `@webiny/api-headless-cms/features/contentEntry/GetEntryById` +- `CreateEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/CreateEntry` + +**Events:** +- `EntryBeforeLockEvent` +- `EntryAfterLockEvent` +- `EntryLockErrorEvent` + +**Errors:** +- `EntryAlreadyLockedError` +- `LockEntryError` + +#### 2. UnlockEntry - Unlock an entry + +**Dependencies:** +- `UnlockEntryRepository` - Internal repository (injected into use case) +- `GetLockRecordUseCase` - Internal use case +- `KickOutCurrentUserUseCase` - Internal use case +- `EventPublisher` - From `@webiny/api-core/features/EventPublisher` +- `IdentityContext` - From `@webiny/api-core/features/IdentityContext` +- `WebsocketsContext` - From `@webiny/api-websockets/features/WebsocketsContext` + +**Repository Dependencies (UnlockEntryRepository):** +- `DeleteEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/DeleteEntry` + +**Events:** +- `EntryBeforeUnlockEvent` +- `EntryAfterUnlockEvent` +- `EntryUnlockErrorEvent` + +**Errors:** +- `LockRecordNotFoundError` +- `NotSameIdentityError` +- `UnlockEntryError` + +#### 3. GetLockRecord - Get lock record for entry + +**Dependencies:** +- `GetLockRecordRepository` - Internal repository (injected into use case) + +**Repository Dependencies (GetLockRecordRepository):** +- `GetEntryByIdUseCase` - From `@webiny/api-headless-cms/features/contentEntry/GetEntryById` + +**Errors:** +- `LockRecordNotFoundError` + +#### 4. ListLockRecords - List all lock records + +**Dependencies:** +- `ListLockRecordsRepository` - Internal repository (injected into use case) + +**Repository Dependencies (ListLockRecordsRepository):** +- `ListEntriesUseCase` - From `@webiny/api-headless-cms/features/contentEntry/ListEntries` + +**Errors:** +- `LockRecordPersistenceError` + +#### 5. UpdateEntryLock - Update lock timestamp + +**Dependencies:** +- `UpdateEntryLockRepository` - Internal repository (injected into use case) +- `GetLockRecordUseCase` - Internal use case + +**Repository Dependencies (UpdateEntryLockRepository):** +- `UpdateEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/UpdateEntry` + +**Errors:** +- `LockRecordNotFoundError` +- `LockRecordPersistenceError` + +#### 6. IsEntryLocked - Check if entry is locked + +**Dependencies:** +- `GetLockRecordUseCase` - Internal use case + +**Returns:** `boolean` + +### Phase 7: Repository Pattern - One Repository Per Use Case + +**Example: LockEntryRepository** + +```typescript +// features/LockEntry/LockEntryRepository.ts +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { LockEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import type { LockRecord } from "../shared/LockRecord.js"; + +class LockEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createEntry: CreateEntryUseCase.Interface + ) {} + + async createLockRecord(record: LockRecord): Promise> { + // Implementation using createEntry + } +} + +export const LockEntryRepositoryImpl = RepositoryAbstraction.createImplementation({ + implementation: LockEntryRepositoryImpl, + dependencies: [CreateEntryUseCase] +}); + +// In feature registration +container.register(LockEntryRepositoryImpl).inSingletonScope(); +``` + +**Example: GetLockRecordRepository** + +```typescript +// features/GetLockRecord/GetLockRecordRepository.ts +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { GetLockRecordRepository as RepositoryAbstraction } from "./abstractions.js"; + +class GetLockRecordRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface + ) {} + + async getLockRecord(id: string): Promise> { + // Implementation using getEntryById + } +} + +export const GetLockRecordRepositoryImpl = RepositoryAbstraction.createImplementation({ + implementation: GetLockRecordRepositoryImpl, + dependencies: [GetEntryByIdUseCase] +}); +``` + +**Example: ListLockRecordsRepository** + +```typescript +// features/ListLockRecords/ListLockRecordsRepository.ts +import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { ListLockRecordsRepository as RepositoryAbstraction } from "./abstractions.js"; + +class ListLockRecordsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private listEntries: ListEntriesUseCase.Interface + ) {} + + async listLockRecords(params: ListParams): Promise> { + // Implementation using listEntries + } +} + +export const ListLockRecordsRepositoryImpl = RepositoryAbstraction.createImplementation({ + implementation: ListLockRecordsRepositoryImpl, + dependencies: [ListEntriesUseCase] +}); +``` + +**Key Principle:** Each repository serves only the needs of its associated use case. No god objects or shared repositories. + +### Phase 8: Feature Registration + +**Main feature:** + +```typescript +// features/RecordLockingManagement/feature.ts +import { createFeature } from "@webiny/feature"; +import { Container } from "@webiny/di"; +import { LockEntryFeature } from "../LockEntry/feature.js"; +import { UnlockEntryFeature } from "../UnlockEntry/feature.js"; +import { GetLockRecordFeature } from "../GetLockRecord/feature.js"; +import { ListLockRecordsFeature } from "../ListLockRecords/feature.js"; +import { UpdateEntryLockFeature } from "../UpdateEntryLock/feature.js"; + +export const RecordLockingManagementFeature = createFeature({ + name: "RecordLockingManagement", + register(container: Container) { + // Register sub-features (each registers its own repository in singleton scope) + LockEntryFeature.register(container); + UnlockEntryFeature.register(container); + GetLockRecordFeature.register(container); + ListLockRecordsFeature.register(container); + UpdateEntryLockFeature.register(container); + } +}); +``` + +**Individual feature example:** + +```typescript +// features/LockEntry/feature.ts +import { createFeature } from "@webiny/feature"; +import { Container } from "@webiny/di"; +import { LockEntryUseCaseImpl } from "./LockEntryUseCase.js"; +import { LockEntryRepositoryImpl } from "./LockEntryRepository.js"; + +export const LockEntryFeature = createFeature({ + name: "LockEntry", + register(container: Container) { + // Register repository in singleton scope + container.register(LockEntryRepositoryImpl).inSingletonScope(); + + // Register use case in transient scope (default) + container.register(LockEntryUseCaseImpl); + } +}); +``` + +## Dependencies from CMS Package + +The following use cases will be injected from `@webiny/api-headless-cms`: + +- `GetEntryByIdUseCase` (from `contentEntry/GetEntryById`) +- `CreateEntryUseCase` (from `contentEntry/CreateEntry`) +- `UpdateEntryUseCase` (from `contentEntry/UpdateEntry`) +- `DeleteEntryUseCase` (from `contentEntry/DeleteEntry`) +- `ListEntriesUseCase` (from `contentEntry/ListEntries`) +- `GetModelUseCase` (from `contentModel/GetModel`) + +## Summary of Key Changes + +1. ✅ **Remove `getManager()`** → Inject entry use cases directly +2. ✅ **Remove PubSub topics** → Use EventPublisher with domain events +3. ✅ **Create proper abstractions** for all use cases +4. ✅ **Create domain-specific errors** extending BaseError +5. ✅ **Register in correct scopes:** + - Use cases: Transient scope (default) + - Repositories: Singleton scope + - Gateways: Singleton scope +6. ✅ **Use existing abstractions** from core packages: + - `IdentityContext` from `@webiny/api-core/features/IdentityContext` + - `WebsocketsContext` from `@webiny/api-websockets/features/WebsocketsContext` +7. ✅ **One repository per use case** - No god objects +8. ✅ **One file per class** rule +9. ✅ **Feature-based folder structure** + +## Migration Checklist + +### Shared Components +- [ ] Create `features/shared/` directory structure +- [ ] Create domain errors in `shared/errors.ts` +- [ ] Create `LockRecord` domain model in `shared/LockRecord.ts` + +### LockEntry Feature +- [ ] Create `features/LockEntry/` directory +- [ ] Create abstractions (use case + repository) +- [ ] Create events (BeforeLock, AfterLock, LockError) +- [ ] Implement `LockEntryRepository` (uses CreateEntryUseCase) +- [ ] Implement `LockEntryUseCase` +- [ ] Create feature registration +- [ ] Register repository in singleton scope, use case in transient scope + +### UnlockEntry Feature +- [ ] Create `features/UnlockEntry/` directory +- [ ] Create abstractions (use case + repository) +- [ ] Create events (BeforeUnlock, AfterUnlock, UnlockError) +- [ ] Implement `UnlockEntryRepository` (uses DeleteEntryUseCase) +- [ ] Implement `UnlockEntryUseCase` +- [ ] Create feature registration +- [ ] Register repository in singleton scope, use case in transient scope + +### GetLockRecord Feature +- [ ] Create `features/GetLockRecord/` directory +- [ ] Create abstractions (use case + repository) +- [ ] Implement `GetLockRecordRepository` (uses GetEntryByIdUseCase) +- [ ] Implement `GetLockRecordUseCase` +- [ ] Create feature registration +- [ ] Register repository in singleton scope, use case in transient scope + +### ListLockRecords Feature +- [ ] Create `features/ListLockRecords/` directory +- [ ] Create abstractions (use case + repository) +- [ ] Implement `ListLockRecordsRepository` (uses ListEntriesUseCase) +- [ ] Implement `ListLockRecordsUseCase` +- [ ] Create feature registration +- [ ] Register repository in singleton scope, use case in transient scope + +### UpdateEntryLock Feature +- [ ] Create `features/UpdateEntryLock/` directory +- [ ] Create abstractions (use case + repository) +- [ ] Implement `UpdateEntryLockRepository` (uses UpdateEntryUseCase) +- [ ] Implement `UpdateEntryLockUseCase` +- [ ] Create feature registration +- [ ] Register repository in singleton scope, use case in transient scope + +### IsEntryLocked Feature +- [ ] Create `features/IsEntryLocked/` directory +- [ ] Create abstractions (use case only) +- [ ] Implement `IsEntryLockedUseCase` (uses GetLockRecordUseCase) +- [ ] Create feature registration +- [ ] Register use case in transient scope + +### KickOutCurrentUser Feature +- [ ] Create `features/KickOutCurrentUser/` directory +- [ ] Create abstractions (use case only) +- [ ] Implement `KickOutCurrentUserUseCase` (uses WebsocketsContext) +- [ ] Create feature registration +- [ ] Register use case in transient scope + +### Composite Feature +- [ ] Create `RecordLockingManagement` feature that registers all sub-features + +### Integration & Cleanup +- [ ] Update GraphQL schema to use new features +- [ ] Update `index.ts` to export features and abstractions +- [ ] Remove old `crud/` directory +- [ ] Remove old `useCases/` directory +- [ ] Update tests to use new feature structure +- [ ] Update documentation diff --git a/packages/api-record-locking/src/domain/abstractions.ts b/packages/api-record-locking/src/domain/abstractions.ts new file mode 100644 index 00000000000..8056c05682b --- /dev/null +++ b/packages/api-record-locking/src/domain/abstractions.ts @@ -0,0 +1,24 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; + +/** + * RecordLockingConfig - Configuration for record locking timeout + */ +export interface IRecordLockingConfig { + /** + * Timeout in milliseconds after which a lock expires + */ + timeout: number; +} + +export const RecordLockingConfig = createAbstraction("RecordLockingConfig"); + +export namespace RecordLockingConfig { + export type Interface = IRecordLockingConfig; +} + +export const RecordLockingModel = createAbstraction("RecordLockingModel"); + +export namespace RecordLockingModel { + export type Interface = CmsModel; +} diff --git a/packages/api-record-locking/src/domain/index.ts b/packages/api-record-locking/src/domain/index.ts new file mode 100644 index 00000000000..56db9493bbd --- /dev/null +++ b/packages/api-record-locking/src/domain/index.ts @@ -0,0 +1,4 @@ +export * from "./abstractions.js"; +export * from "./errors.js"; +export * from "./LockRecord.js"; +export * from "./types.js"; diff --git a/packages/api-record-locking/src/domain/model.ts b/packages/api-record-locking/src/domain/model.ts new file mode 100644 index 00000000000..0e58ade37e3 --- /dev/null +++ b/packages/api-record-locking/src/domain/model.ts @@ -0,0 +1,150 @@ +import { createCmsModel, createPrivateModel } from "@webiny/api-headless-cms"; + +export const RECORD_LOCKING_MODEL_ID = "wby_recordLocking"; + +export const createLockingModel = () => { + return createCmsModel( + createPrivateModel({ + modelId: RECORD_LOCKING_MODEL_ID, + name: "Record Lock Tracking", + fields: [ + { + id: "targetId", + type: "text", + fieldId: "targetId", + storageId: "text@targetId", + label: "Target ID", + validation: [ + { + name: "required", + message: "Target ID is required." + } + ] + }, + /** + * Since we need a generic way to track records, we will use type to determine if it's a cms record or a page or a form, etc... + * Update IHeadlessCmsLockRecordValues in types.ts file with additional fields as required. + * + * @see IHeadlessCmsLockRecordValues + */ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Record Type", + validation: [ + { + name: "required", + message: "Record type is required." + } + ] + }, + { + id: "actions", + type: "object", + fieldId: "actions", + storageId: "object@actions", + label: "Actions", + multipleValues: true, + settings: { + fields: [ + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Action Type", + validation: [ + { + name: "required", + message: "Action type is required." + } + ] + }, + { + id: "message", + type: "text", + fieldId: "message", + storageId: "text@message", + label: "Message" + }, + { + id: "createdBy", + type: "object", + fieldId: "createdBy", + storageId: "object@createdBy", + label: "Created By", + validation: [ + { + name: "required", + message: "Created by is required." + } + ], + settings: { + fields: [ + { + id: "id", + type: "text", + fieldId: "id", + storageId: "text@id", + label: "ID", + validation: [ + { + name: "required", + message: "ID is required." + } + ] + }, + { + id: "displayName", + type: "text", + fieldId: "displayName", + storageId: "text@displayName", + label: "Display Name", + validation: [ + { + name: "required", + message: "Display name is required." + } + ] + }, + { + id: "type", + type: "text", + fieldId: "type", + storageId: "text@type", + label: "Type", + validation: [ + { + name: "required", + message: "Type is required." + } + ] + } + ] + } + }, + { + id: "createdOn", + type: "datetime", + fieldId: "createdOn", + storageId: "datetime@createdOn", + settings: { + type: "dateTimeWithoutTimezone" + }, + label: "Created On", + validation: [ + { + name: "required", + message: "Created on is required." + } + ] + } + ] + } + } + ] + }) + ); +}; diff --git a/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts new file mode 100644 index 00000000000..b868510bf54 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { createIdentifier } from "@webiny/utils"; +import { GetLockRecordRepository as RepositoryAbstraction } from "./abstractions.js"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordValues } from "~/domain/types.js"; +import { LockRecordNotFoundError, LockRecordPersistenceError } from "~/domain/errors.js"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; + +class GetLockRecordRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private model: RecordLockingModel.Interface, + private config: RecordLockingConfig.Interface, + private getEntryById: GetEntryByIdUseCase.Interface + ) {} + + async get(id: string): Promise> { + const recordId = createLockRecordDatabaseId(id); + const entryId = createIdentifier({ + id: recordId, + version: 1 + }); + + const result = await this.getEntryById.execute(this.model, entryId); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new LockRecordNotFoundError({ id: entryId })); + } + + return Result.fail(new LockRecordPersistenceError(result.error)); + } + + const entry = result.value; + + return Result.ok(new LockRecord(entry, this.config.timeout)); + } +} + +export const GetLockRecordRepository = RepositoryAbstraction.createImplementation({ + implementation: GetLockRecordRepositoryImpl, + dependencies: [RecordLockingModel, RecordLockingConfig, GetEntryByIdUseCase] +}); diff --git a/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordUseCase.ts new file mode 100644 index 00000000000..eed91ed59e7 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordUseCase.ts @@ -0,0 +1,22 @@ +import { Result } from "@webiny/feature/api"; +import { + GetLockRecordUseCase as UseCaseAbstraction, + GetLockRecordRepository, + GetLockRecordInput +} from "./abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; + +class GetLockRecordUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private repository: GetLockRecordRepository.Interface) {} + + async execute( + input: GetLockRecordInput + ): Promise> { + return await this.repository.get(input.id); + } +} + +export const GetLockRecordUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetLockRecordUseCaseImpl, + dependencies: [GetLockRecordRepository] +}); diff --git a/packages/api-record-locking/src/features/GetLockRecord/abstractions.ts b/packages/api-record-locking/src/features/GetLockRecord/abstractions.ts new file mode 100644 index 00000000000..5af48fff813 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockRecord/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import { LockRecordNotFoundError, type LockRecordPersistenceError } from "~/domain/errors.js"; + +// Input types +export interface GetLockRecordInput { + id: string; + type: LockRecordEntryType; +} + +/** + * GetLockRecord Use Case - Retrieves a lock record for a given entry + */ +export interface IGetLockRecordUseCase { + execute(input: GetLockRecordInput): Promise>; +} + +export interface IGetLockRecordUseCaseErrors { + notFound: LockRecordNotFoundError; + persistence: LockRecordPersistenceError; +} + +type UseCaseError = IGetLockRecordUseCaseErrors[keyof IGetLockRecordUseCaseErrors]; + +export const GetLockRecordUseCase = + createAbstraction("GetLockRecordUseCase"); + +export namespace GetLockRecordUseCase { + export type Interface = IGetLockRecordUseCase; + export type Error = UseCaseError; +} + +/** + * GetLockRecordRepository - Fetches lock record from storage + */ +export interface IGetLockRecordRepository { + get(id: string): Promise>; +} + +export interface IGetLockRecordRepositoryErrors { + notFound: LockRecordNotFoundError; + persistence: LockRecordPersistenceError; +} + +type RepositoryError = IGetLockRecordRepositoryErrors[keyof IGetLockRecordRepositoryErrors]; + +export const GetLockRecordRepository = + createAbstraction("GetLockRecordRepository"); + +export namespace GetLockRecordRepository { + export type Interface = IGetLockRecordRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/GetLockRecord/feature.ts b/packages/api-record-locking/src/features/GetLockRecord/feature.ts new file mode 100644 index 00000000000..bfe6425d3ed --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockRecord/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetLockRecordUseCase } from "./GetLockRecordUseCase.js"; +import { GetLockRecordRepository } from "./GetLockRecordRepository.js"; + +export const GetLockRecordFeature = createFeature({ + name: "GetLockRecord", + register(container) { + container.register(GetLockRecordUseCase); + container.register(GetLockRecordRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/GetLockRecord/index.ts b/packages/api-record-locking/src/features/GetLockRecord/index.ts new file mode 100644 index 00000000000..8f92f81935f --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockRecord/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export { GetLockRecordFeature } from "./feature.js"; diff --git a/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts new file mode 100644 index 00000000000..678244a5413 --- /dev/null +++ b/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts @@ -0,0 +1,46 @@ +import { Result } from "@webiny/feature/api"; +import { + IsEntryLockedUseCase as UseCaseAbstraction, + IsEntryLockedInput +} from "./abstractions.js"; +import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { LockRecordNotFoundError } from "~/domain/errors.js"; + +class IsEntryLockedUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getLockRecord: GetLockRecordUseCase.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute(input: IsEntryLockedInput): Promise> { + const result = await this.getLockRecord.execute(input); + + if (result.isFail()) { + // If not found, entry is not locked + if (result.error instanceof LockRecordNotFoundError) { + return Result.ok(false); + } + // Propagate other errors + return Result.fail(result.error); + } + + const record = result.value; + + // If expired, entry is not locked + if (record.isExpired()) { + return Result.ok(false); + } + + // Check if locked by someone else + const identity = this.identityContext.getIdentity(); + const isLockedByOther = record.lockedBy.id !== identity.id; + + return Result.ok(isLockedByOther); + } +} + +export const IsEntryLockedUseCase = UseCaseAbstraction.createImplementation({ + implementation: IsEntryLockedUseCaseImpl, + dependencies: [GetLockRecordUseCase, IdentityContext] +}); diff --git a/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts b/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts new file mode 100644 index 00000000000..e0db506363f --- /dev/null +++ b/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts @@ -0,0 +1,31 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import type { LockRecordPersistenceError } from "~/domain/errors.js"; + +// Input types +export interface IsEntryLockedInput { + id: string; + type: LockRecordEntryType; +} + +/** + * IsEntryLocked Use Case - Checks if an entry is locked by another user + * Returns true if locked by someone else, false if not locked or locked by current user + */ +export interface IIsEntryLockedUseCase { + execute(input: IsEntryLockedInput): Promise>; +} + +export interface IIsEntryLockedUseCaseErrors { + persistence: LockRecordPersistenceError; +} + +type UseCaseError = IIsEntryLockedUseCaseErrors[keyof IIsEntryLockedUseCaseErrors]; + +export const IsEntryLockedUseCase = createAbstraction("IsEntryLockedUseCase"); + +export namespace IsEntryLockedUseCase { + export type Interface = IIsEntryLockedUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-record-locking/src/features/IsEntryLocked/feature.ts b/packages/api-record-locking/src/features/IsEntryLocked/feature.ts new file mode 100644 index 00000000000..3cd81304915 --- /dev/null +++ b/packages/api-record-locking/src/features/IsEntryLocked/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { IsEntryLockedUseCase } from "./IsEntryLockedUseCase.js"; + +export const IsEntryLockedFeature = createFeature({ + name: "IsEntryLocked", + register(container) { + container.register(IsEntryLockedUseCase); + } +}); diff --git a/packages/api-record-locking/src/features/IsEntryLocked/index.ts b/packages/api-record-locking/src/features/IsEntryLocked/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/IsEntryLocked/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts new file mode 100644 index 00000000000..a42428a1c9a --- /dev/null +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -0,0 +1,48 @@ +import { Result } from "@webiny/feature/api"; +import { WebsocketsContext } from "@webiny/api-websockets/features/WebsocketsContext"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { parseIdentifier } from "@webiny/utils"; +import { KickOutCurrentUserUseCase as UseCaseAbstraction } from "./abstractions.js"; +import type { ILockRecord } from "~/domain/index.js"; + +class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private websocketsContext: WebsocketsContext.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute(record: ILockRecord): Promise> { + const { lockedBy, id } = record; + + const { id: entryId } = parseIdentifier(id); + const identity = this.identityContext.getIdentity(); + + /** + * We do not want any errors to leak out of this method. + */ + try { + await this.websocketsContext.send( + { id: lockedBy.id }, + { + action: `recordLocking.entry.kickOut.${entryId}`, + data: { + record: record.toObject(), + user: identity + } + } + ); + } catch (error) { + console.error( + `Could not send the kickOut message to a user with identity id: ${lockedBy.id}. More info in next log line.` + ); + console.info(error); + } + + return Result.ok(); + } +} + +export const KickOutCurrentUserUseCase = UseCaseAbstraction.createImplementation({ + implementation: KickOutCurrentUserUseCaseImpl, + dependencies: [WebsocketsContext, IdentityContext] +}); diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/abstractions.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/abstractions.ts new file mode 100644 index 00000000000..9fe8d86c770 --- /dev/null +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/abstractions.ts @@ -0,0 +1,25 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; + +/** + * KickOutCurrentUser Use Case - Sends websocket message to notify locked-out user + */ +export interface IKickOutCurrentUserUseCase { + execute(record: ILockRecord): Promise>; +} + +export interface IKickOutCurrentUserUseCaseErrors { + // This use case swallows errors and logs them, so no errors are exposed +} + +type UseCaseError = IKickOutCurrentUserUseCaseErrors[keyof IKickOutCurrentUserUseCaseErrors]; + +export const KickOutCurrentUserUseCase = createAbstraction( + "KickOutCurrentUserUseCase" +); + +export namespace KickOutCurrentUserUseCase { + export type Interface = IKickOutCurrentUserUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/feature.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/feature.ts new file mode 100644 index 00000000000..5b247c0dd97 --- /dev/null +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { KickOutCurrentUserUseCase } from "./KickOutCurrentUserUseCase.js"; + +export const KickOutCurrentUserFeature = createFeature({ + name: "KickOutCurrentUser", + register(container) { + container.register(KickOutCurrentUserUseCase); + } +}); diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/index.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsRepository.ts b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsRepository.ts new file mode 100644 index 00000000000..a3e7d694bf3 --- /dev/null +++ b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsRepository.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { + ListLockRecordsRepository as RepositoryAbstraction, + ListLockRecordsInput, + ListLockRecordsOutput +} from "./abstractions.js"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import { LockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordValues } from "~/domain/types.js"; +import { LockRecordPersistenceError } from "~/domain/errors.js"; +import { convertWhereCondition } from "~/utils/convertWhereCondition.js"; + +class ListLockRecordsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private model: RecordLockingModel.Interface, + private config: RecordLockingConfig.Interface, + private listEntries: ListLatestEntriesUseCase.Interface + ) {} + + async execute( + input?: ListLockRecordsInput + ): Promise> { + try { + const params = { + ...input, + where: convertWhereCondition(input?.where || {}) + }; + + const result = await this.listEntries.execute(this.model, params); + + if (result.isFail()) { + return Result.fail(new LockRecordPersistenceError(result.error)); + } + + const [entries, meta] = result.value; + + const items = entries.map(entry => new LockRecord(entry, this.config.timeout)); + + return Result.ok({ + items, + meta + }); + } catch (error) { + return Result.fail(new LockRecordPersistenceError(error as Error)); + } + } +} + +export const ListLockRecordsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListLockRecordsRepositoryImpl, + dependencies: [RecordLockingModel, RecordLockingConfig, ListLatestEntriesUseCase] +}); diff --git a/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts new file mode 100644 index 00000000000..f3c08a05035 --- /dev/null +++ b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts @@ -0,0 +1,38 @@ +import { Result } from "@webiny/feature/api"; +import { + ListLockRecordsUseCase as UseCaseAbstraction, + ListLockRecordsRepository, + ListLockRecordsInput, + ListLockRecordsOutput +} from "./abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { RecordLockingConfig } from "~/domain/abstractions.js"; + +class ListLockRecordsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private repository: ListLockRecordsRepository.Interface, + private identityContext: IdentityContext.Interface, + private config: RecordLockingConfig.Interface + ) {} + + async execute(input?: ListLockRecordsInput): Promise> { + const identity = this.identityContext.getIdentity(); + + // Filter out expired locks and exclude current user's locks + const enhancedInput: ListLockRecordsInput = { + ...input, + where: { + ...input?.where, + createdBy_not: identity.id, + savedOn_gte: new Date(new Date().getTime() - this.config.timeout) + } + }; + + return await this.repository.execute(enhancedInput); + } +} + +export const ListLockRecordsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListLockRecordsUseCaseImpl, + dependencies: [ListLockRecordsRepository, IdentityContext, RecordLockingConfig] +}); diff --git a/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts b/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts new file mode 100644 index 00000000000..a5404228340 --- /dev/null +++ b/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts @@ -0,0 +1,53 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordPersistenceError } from "~/domain/errors.js"; +import type { CmsEntryListParams, CmsEntryMeta } from "@webiny/api-headless-cms/types"; + +// Input/Output types +export type ListLockRecordsInput = Pick; + +export interface ListLockRecordsOutput { + items: ILockRecord[]; + meta: CmsEntryMeta; +} + +/** + * ListLockRecords Use Case - Lists active lock records (filters out expired, excludes current user) + */ +export interface IListLockRecordsUseCase { + execute(input?: ListLockRecordsInput): Promise>; +} + +export interface IListLockRecordsUseCaseErrors { + persistence: LockRecordPersistenceError; +} + +type UseCaseError = IListLockRecordsUseCaseErrors[keyof IListLockRecordsUseCaseErrors]; + +export const ListLockRecordsUseCase = createAbstraction("ListLockRecordsUseCase"); + +export namespace ListLockRecordsUseCase { + export type Interface = IListLockRecordsUseCase; + export type Error = UseCaseError; +} + +/** + * ListLockRecordsRepository - Fetches lock records from storage with filtering + */ +export interface IListLockRecordsRepository { + execute(input?: ListLockRecordsInput): Promise>; +} + +export interface IListLockRecordsRepositoryErrors { + persistence: LockRecordPersistenceError; +} + +type RepositoryError = IListLockRecordsRepositoryErrors[keyof IListLockRecordsRepositoryErrors]; + +export const ListLockRecordsRepository = createAbstraction("ListLockRecordsRepository"); + +export namespace ListLockRecordsRepository { + export type Interface = IListLockRecordsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/ListLockRecords/feature.ts b/packages/api-record-locking/src/features/ListLockRecords/feature.ts new file mode 100644 index 00000000000..71abc306ba8 --- /dev/null +++ b/packages/api-record-locking/src/features/ListLockRecords/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase.js"; +import { ListLockRecordsRepository } from "./ListLockRecordsRepository.js"; + +export const ListLockRecordsFeature = createFeature({ + name: "ListLockRecords", + register(container) { + container.register(ListLockRecordsUseCase); + container.register(ListLockRecordsRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/ListLockRecords/index.ts b/packages/api-record-locking/src/features/ListLockRecords/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/ListLockRecords/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index d03bed6f913..d1842f5f3cf 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -3,25 +3,27 @@ import { ContextPlugin } from "@webiny/api"; import type { Context } from "~/types.js"; import { createRecordLockingCrud } from "~/crud/crud.js"; import { createLockingModel } from "~/crud/model.js"; -import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; +import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/index.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; +import { RECORD_LOCKING_MODEL_ID } from "~/domain/model.js"; export interface ICreateContextPluginParams { /** - * A number of seconds after last activity to wait before the record is automatically unlocked. + * A number of seconds after the last activity to wait before the record is automatically unlocked. */ timeout?: number; } const createContextPlugin = (params?: ICreateContextPluginParams) => { const plugin = new ContextPlugin(async context => { - if (!context.wcp.canUseRecordLocking()) { - return; - } + const wcp = context.container.resolve(WcpContext); + const getModel = context.container.resolve(GetModelUseCase); - const ready = await isHeadlessCmsReady(context); - if (!ready) { + if (!wcp.canUseRecordLocking()) { return; } + context.plugins.register(createLockingModel()); context.recordLocking = await createRecordLockingCrud({ @@ -30,7 +32,16 @@ const createContextPlugin = (params?: ICreateContextPluginParams) => { }); const graphQlPlugin = await createGraphQLSchema({ context }); + context.plugins.register(graphQlPlugin); + + const recordLockingModel = await getModel.execute(RECORD_LOCKING_MODEL_ID); + + // Register new abstractions + context.container.registerInstance(RecordLockingModel, recordLockingModel.value); + context.container.registerInstance(RecordLockingConfig, { + timeout: params?.timeout + }); }); plugin.name = "context.recordLocking"; From e0c01dcd1c3010c681931e0d062490988bbf6f3a Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 11:24:16 +0100 Subject: [PATCH 46/71] wip: migrate record locking --- .../features/LockEntry/LockEntryRepository.ts | 51 ++++++++++ .../features/LockEntry/LockEntryUseCase.ts | 44 +++++++++ .../src/features/LockEntry/abstractions.ts | 53 ++++++++++ .../src/features/LockEntry/feature.ts | 11 +++ .../src/features/LockEntry/index.ts | 1 + .../src/features/RecordLockingFeature.ts | 39 ++++++++ .../UnlockEntry/UnlockEntryRepository.ts | 44 +++++++++ .../UnlockEntry/UnlockEntryUseCase.ts | 99 +++++++++++++++++++ .../src/features/UnlockEntry/abstractions.ts | 62 ++++++++++++ .../src/features/UnlockEntry/feature.ts | 11 +++ .../src/features/UnlockEntry/index.ts | 1 + .../UpdateEntryLockRepository.ts | 81 +++++++++++++++ .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 72 ++++++++++++++ .../features/UpdateEntryLock/abstractions.ts | 54 ++++++++++ .../src/features/UpdateEntryLock/feature.ts | 11 +++ .../src/features/UpdateEntryLock/index.ts | 1 + packages/api-record-locking/src/index.ts | 14 ++- .../src/utils/hasFullAccessPermission.ts | 19 ++++ 18 files changed, 663 insertions(+), 5 deletions(-) create mode 100644 packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/abstractions.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/feature.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/index.ts create mode 100644 packages/api-record-locking/src/features/RecordLockingFeature.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/abstractions.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/feature.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/index.ts create mode 100644 packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockRepository.ts create mode 100644 packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts create mode 100644 packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts create mode 100644 packages/api-record-locking/src/features/UpdateEntryLock/feature.ts create mode 100644 packages/api-record-locking/src/features/UpdateEntryLock/index.ts create mode 100644 packages/api-record-locking/src/utils/hasFullAccessPermission.ts diff --git a/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts b/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts new file mode 100644 index 00000000000..8c6aee47d60 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts @@ -0,0 +1,51 @@ +import { Result } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import type { CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { LockEntryRepository as RepositoryAbstraction, LockEntryInput } from "./abstractions.js"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordValues } from "~/domain/types.js"; +import { LockRecordPersistenceError } from "~/domain/errors.js"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; + +class LockEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private model: RecordLockingModel.Interface, + private config: RecordLockingConfig.Interface, + private createEntry: CreateEntryUseCase.Interface + ) {} + + async create(input: LockEntryInput): Promise> { + try { + const id = createLockRecordDatabaseId(input.id); + + const values: LockRecordValues = { + targetId: input.id, + type: input.type, + actions: [] + }; + + const result = await this.createEntry.execute(this.model, { + id, + values + }); + + if (result.isFail()) { + return Result.fail(new LockRecordPersistenceError(result.error)); + } + + const entry = result.value as CmsEntry; + const lockRecord = new LockRecord(entry, this.config.timeout); + + return Result.ok(lockRecord); + } catch (error) { + return Result.fail(new LockRecordPersistenceError(error as Error)); + } + } +} + +export const LockEntryRepository = RepositoryAbstraction.createImplementation({ + implementation: LockEntryRepositoryImpl, + dependencies: [RecordLockingModel, RecordLockingConfig, CreateEntryUseCase] +}); diff --git a/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts b/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts new file mode 100644 index 00000000000..cd12cc50f84 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { + LockEntryUseCase as UseCaseAbstraction, + LockEntryRepository, + LockEntryInput +} from "./abstractions.js"; +import { IsEntryLockedUseCase } from "../IsEntryLocked/abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { EntryAlreadyLockedError, LockEntryError } from "~/domain/errors.js"; + +class LockEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private isEntryLocked: IsEntryLockedUseCase.Interface, + private repository: LockEntryRepository.Interface + ) {} + + async execute(input: LockEntryInput): Promise> { + // Check if entry is already locked + const lockedResult = await this.isEntryLocked.execute(input); + + if (lockedResult.isFail()) { + return Result.fail(lockedResult.error); + } + + if (lockedResult.value) { + return Result.fail(new EntryAlreadyLockedError({ id: input.id, type: input.type })); + } + + // Create the lock + const result = await this.repository.create(input); + + if (result.isFail()) { + // Wrap persistence errors in domain error + return Result.fail(new LockEntryError(result.error)); + } + + return Result.ok(result.value); + } +} + +export const LockEntryUseCase = UseCaseAbstraction.createImplementation({ + implementation: LockEntryUseCaseImpl, + dependencies: [IsEntryLockedUseCase, LockEntryRepository] +}); diff --git a/packages/api-record-locking/src/features/LockEntry/abstractions.ts b/packages/api-record-locking/src/features/LockEntry/abstractions.ts new file mode 100644 index 00000000000..c9d723275e8 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/abstractions.ts @@ -0,0 +1,53 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import type { EntryAlreadyLockedError, LockEntryError, LockRecordPersistenceError } from "~/domain/errors.js"; + +// Input types +export interface LockEntryInput { + id: string; + type: LockRecordEntryType; +} + +/** + * LockEntry Use Case - Creates a lock on an entry + */ +export interface ILockEntryUseCase { + execute(input: LockEntryInput): Promise>; +} + +export interface ILockEntryUseCaseErrors { + alreadyLocked: EntryAlreadyLockedError; + persistence: LockRecordPersistenceError; + lockError: LockEntryError; +} + +type UseCaseError = ILockEntryUseCaseErrors[keyof ILockEntryUseCaseErrors]; + +export const LockEntryUseCase = createAbstraction("LockEntryUseCase"); + +export namespace LockEntryUseCase { + export type Interface = ILockEntryUseCase; + export type Error = UseCaseError; +} + +/** + * LockEntryRepository - Creates lock record in storage + */ +export interface ILockEntryRepository { + create(input: LockEntryInput): Promise>; +} + +export interface ILockEntryRepositoryErrors { + persistence: LockRecordPersistenceError; +} + +type RepositoryError = ILockEntryRepositoryErrors[keyof ILockEntryRepositoryErrors]; + +export const LockEntryRepository = createAbstraction("LockEntryRepository"); + +export namespace LockEntryRepository { + export type Interface = ILockEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/LockEntry/feature.ts b/packages/api-record-locking/src/features/LockEntry/feature.ts new file mode 100644 index 00000000000..a97e7086f87 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { LockEntryUseCase } from "./LockEntryUseCase.js"; +import { LockEntryRepository } from "./LockEntryRepository.js"; + +export const LockEntryFeature = createFeature({ + name: "LockEntry", + register(container) { + container.register(LockEntryUseCase); + container.register(LockEntryRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/LockEntry/index.ts b/packages/api-record-locking/src/features/LockEntry/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/features/RecordLockingFeature.ts b/packages/api-record-locking/src/features/RecordLockingFeature.ts new file mode 100644 index 00000000000..9df9f4799f5 --- /dev/null +++ b/packages/api-record-locking/src/features/RecordLockingFeature.ts @@ -0,0 +1,39 @@ +import { createFeature } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import { GetLockRecordFeature } from "./GetLockRecord/feature.js"; +import { KickOutCurrentUserFeature } from "./KickOutCurrentUser/feature.js"; +import { ListLockRecordsFeature } from "./ListLockRecords/feature.js"; +import { IsEntryLockedFeature } from "./IsEntryLocked/feature.js"; +import { LockEntryFeature } from "./LockEntry/feature.js"; +import { UpdateEntryLockFeature } from "./UpdateEntryLock/feature.js"; +import { UnlockEntryFeature } from "./UnlockEntry/feature.js"; + +export interface RecordLockingParams { + /** + * Timeout in milliseconds after which a lock expires + */ + timeout: number; + /** + * The CMS model for storing lock records + */ + model: CmsModel; +} + +export const RecordLockingFeature = createFeature({ + name: "RecordLockingManagement", + register(container, params: RecordLockingParams) { + // Register domain abstractions + container.registerInstance(RecordLockingConfig, { timeout: params.timeout }); + container.registerInstance(RecordLockingModel, params.model); + + // Register all sub-features + GetLockRecordFeature.register(container); + KickOutCurrentUserFeature.register(container); + ListLockRecordsFeature.register(container); + IsEntryLockedFeature.register(container); + LockEntryFeature.register(container); + UpdateEntryLockFeature.register(container); + UnlockEntryFeature.register(container); + } +}); diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts new file mode 100644 index 00000000000..c3da6437bed --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts @@ -0,0 +1,44 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { createIdentifier } from "@webiny/utils"; +import { UnlockEntryRepository as RepositoryAbstraction } from "./abstractions.js"; +import { RecordLockingModel } from "~/domain/abstractions.js"; +import { LockRecordNotFoundError, UnlockEntryError } from "~/domain/errors.js"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; + +class UnlockEntryRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private model: RecordLockingModel.Interface, + private deleteEntry: DeleteEntryUseCase.Interface + ) {} + + async delete(lockRecordId: string): Promise> { + try { + const entryId = createLockRecordDatabaseId(lockRecordId); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + + const result = await this.deleteEntry.execute(this.model, id, { + permanently: true + }); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new LockRecordNotFoundError({ id: entryId })); + } + return Result.fail(new UnlockEntryError(result.error)); + } + + return Result.ok(); + } catch (error) { + return Result.fail(new UnlockEntryError(error as Error)); + } + } +} + +export const UnlockEntryRepository = RepositoryAbstraction.createImplementation({ + implementation: UnlockEntryRepositoryImpl, + dependencies: [RecordLockingModel, DeleteEntryUseCase] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts new file mode 100644 index 00000000000..36df089af56 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts @@ -0,0 +1,99 @@ +import { Result } from "@webiny/feature/api"; +import { + UnlockEntryUseCase as UseCaseAbstraction, + UnlockEntryRepository, + UnlockEntryInput +} from "./abstractions.js"; +import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; +import { KickOutCurrentUserUseCase } from "../KickOutCurrentUser/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecordNotFoundError, NotSameIdentityError } from "~/domain/errors.js"; +import { hasFullAccessPermission } from "~/utils/hasFullAccessPermission.js"; + +class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getLockRecord: GetLockRecordUseCase.Interface, + private kickOutCurrentUser: KickOutCurrentUserUseCase.Interface, + private repository: UnlockEntryRepository.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute(input: UnlockEntryInput): Promise> { + // Get the lock record + const recordResult = await this.getLockRecord.execute(input); + + // If not found or expired, attempt cleanup and return error + if (recordResult.isFail()) { + if (recordResult.error instanceof LockRecordNotFoundError) { + // Try to cleanup any stale data + await this.repository.delete(input.id); + } + + return Result.fail(recordResult.error); + } + + const record = recordResult.value; + + // If expired, cleanup and return error + if (record.isExpired()) { + await this.repository.delete(record.id); + return Result.fail(new LockRecordNotFoundError({ id: input.id })); + } + + // Check if user is the owner + const identity = this.identityContext.getIdentity(); + const isSameUser = record.lockedBy.id === identity.id; + + let shouldKickOut = false; + + // If not the owner, check if force unlock is allowed + if (!isSameUser) { + if (!input.force) { + return Result.fail( + new NotSameIdentityError({ + currentId: identity.id, + targetId: record.lockedBy.id + }) + ); + } + + // Check if user has permission to force unlock + const hasAccess = await hasFullAccessPermission(this.identityContext); + if (!hasAccess) { + return Result.fail( + new NotSameIdentityError({ + currentId: identity.id, + targetId: record.lockedBy.id + }) + ); + } + + shouldKickOut = true; + } + + // Delete the lock record + const deleteResult = await this.repository.delete(record.id); + + if (deleteResult.isFail()) { + return Result.fail(deleteResult.error); + } + + // If forced by another user, kick out the original lock owner + if (shouldKickOut) { + await this.kickOutCurrentUser.execute(record); + } + + return Result.ok(record); + } +} + +export const UnlockEntryUseCase = UseCaseAbstraction.createImplementation({ + implementation: UnlockEntryUseCaseImpl, + dependencies: [ + GetLockRecordUseCase, + KickOutCurrentUserUseCase, + UnlockEntryRepository, + IdentityContext + ] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts b/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts new file mode 100644 index 00000000000..e2dfce5fe6c --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts @@ -0,0 +1,62 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import { + type LockRecordNotFoundError, + LockRecordPersistenceError, + type NotSameIdentityError, + type UnlockEntryError +} from "~/domain/errors.js"; + +// Input types +export interface UnlockEntryInput { + id: string; + type: LockRecordEntryType; + force?: boolean; +} + +/** + * UnlockEntry Use Case - Unlocks an entry by deleting the lock record + */ +export interface IUnlockEntryUseCase { + execute(input: UnlockEntryInput): Promise>; +} + +export interface IUnlockEntryUseCaseErrors { + notFound: LockRecordNotFoundError; + notSameIdentity: NotSameIdentityError; + unlockError: UnlockEntryError; + persistence: LockRecordPersistenceError; +} + +type UseCaseError = IUnlockEntryUseCaseErrors[keyof IUnlockEntryUseCaseErrors]; + +export const UnlockEntryUseCase = createAbstraction("UnlockEntryUseCase"); + +export namespace UnlockEntryUseCase { + export type Interface = IUnlockEntryUseCase; + export type Error = UseCaseError; +} + +/** + * UnlockEntryRepository - Deletes lock record from storage + */ +export interface IUnlockEntryRepository { + delete(lockRecordId: string): Promise>; +} + +export interface IUnlockEntryRepositoryErrors { + notFound: LockRecordNotFoundError; + unlockError: UnlockEntryError; +} + +type RepositoryError = IUnlockEntryRepositoryErrors[keyof IUnlockEntryRepositoryErrors]; + +export const UnlockEntryRepository = + createAbstraction("UnlockEntryRepository"); + +export namespace UnlockEntryRepository { + export type Interface = IUnlockEntryRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/UnlockEntry/feature.ts b/packages/api-record-locking/src/features/UnlockEntry/feature.ts new file mode 100644 index 00000000000..259415425fb --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UnlockEntryUseCase } from "./UnlockEntryUseCase.js"; +import { UnlockEntryRepository } from "./UnlockEntryRepository.js"; + +export const UnlockEntryFeature = createFeature({ + name: "UnlockEntry", + register(container) { + container.register(UnlockEntryUseCase); + container.register(UnlockEntryRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/UnlockEntry/index.ts b/packages/api-record-locking/src/features/UnlockEntry/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockRepository.ts b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockRepository.ts new file mode 100644 index 00000000000..8efe4046748 --- /dev/null +++ b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockRepository.ts @@ -0,0 +1,81 @@ +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { Result } from "@webiny/feature/api"; +import { createIdentifier } from "@webiny/utils"; +import { UpdateEntryLockRepository as RepositoryAbstraction } from "./abstractions.js"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordValues } from "~/domain/types.js"; +import { LockRecordPersistenceError } from "~/domain/errors.js"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; + +class UpdateEntryLockRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private model: RecordLockingModel.Interface, + private config: RecordLockingConfig.Interface, + private identityContext: IdentityContext.Interface, + private updateEntry: UpdateEntryUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface + ) {} + + async update( + lockRecordId: string, + updateOwner: boolean + ): Promise> { + try { + const entryId = createLockRecordDatabaseId(lockRecordId); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + + const identity = this.identityContext.getIdentity(); + const now = new Date().toISOString(); + + // Build update data + const updateData: any = { + savedOn: now + }; + + // If updating owner (expired lock), also update created fields + if (updateOwner) { + updateData.createdOn = now; + updateData.createdBy = identity; + updateData.savedBy = identity; + } + + const result = await this.updateEntry.execute(this.model, id, updateData); + + if (result.isFail()) { + return Result.fail(new LockRecordPersistenceError(result.error)); + } + + // Fetch the updated entry to return full lock record + const getResult = await this.getEntryById.execute(this.model, id); + + if (getResult.isFail()) { + return Result.fail(new LockRecordPersistenceError(getResult.error)); + } + + const entry = getResult.value; + const lockRecord = new LockRecord(entry, this.config.timeout); + + return Result.ok(lockRecord); + } catch (error) { + return Result.fail(new LockRecordPersistenceError(error as Error)); + } + } +} + +export const UpdateEntryLockRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateEntryLockRepositoryImpl, + dependencies: [ + RecordLockingModel, + RecordLockingConfig, + IdentityContext, + UpdateEntryUseCase, + GetEntryByIdUseCase + ] +}); diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts new file mode 100644 index 00000000000..b87a2678e2c --- /dev/null +++ b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts @@ -0,0 +1,72 @@ +import { Result } from "@webiny/feature/api"; +import { + UpdateEntryLockUseCase as UseCaseAbstraction, + UpdateEntryLockRepository, + UpdateEntryLockInput +} from "./abstractions.js"; +import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; +import { LockEntryUseCase } from "../LockEntry/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecordNotFoundError, NotSameIdentityError, UpdateEntryLockError } from "~/domain/errors.js"; + +class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getLockRecord: GetLockRecordUseCase.Interface, + private lockEntry: LockEntryUseCase.Interface, + private repository: UpdateEntryLockRepository.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute(input: UpdateEntryLockInput): Promise> { + // Try to get existing lock record + const recordResult = await this.getLockRecord.execute(input); + + // If doesn't exist, create a new lock + if (recordResult.isFail()) { + if (recordResult.error instanceof LockRecordNotFoundError) { + const lockResult = await this.lockEntry.execute(input); + if (lockResult.isFail()) { + return Result.fail(new UpdateEntryLockError(lockResult.error)); + } + return Result.ok(lockResult.value); + } + return Result.fail(recordResult.error); + } + + const record = recordResult.value; + + // If expired, update with current user as new owner + if (record.isExpired()) { + const updateResult = await this.repository.update(record.id, true); + if (updateResult.isFail()) { + return Result.fail(new UpdateEntryLockError(updateResult.error)); + } + return Result.ok(updateResult.value); + } + + // If not expired, validate same owner + const identity = this.identityContext.getIdentity(); + if (record.lockedBy.id !== identity.id) { + return Result.fail( + new NotSameIdentityError({ + currentId: identity.id, + targetId: record.lockedBy.id + }) + ); + } + + // Update timestamp only + const updateResult = await this.repository.update(record.id, false); + if (updateResult.isFail()) { + return Result.fail(new UpdateEntryLockError(updateResult.error)); + } + + return Result.ok(updateResult.value); + } +} + +export const UpdateEntryLockUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateEntryLockUseCaseImpl, + dependencies: [GetLockRecordUseCase, LockEntryUseCase, UpdateEntryLockRepository, IdentityContext] +}); diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts new file mode 100644 index 00000000000..4c7f1a1b61c --- /dev/null +++ b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts @@ -0,0 +1,54 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import type { LockRecordNotFoundError, LockRecordPersistenceError, NotSameIdentityError, UpdateEntryLockError } from "~/domain/errors.js"; + +// Input types +export interface UpdateEntryLockInput { + id: string; + type: LockRecordEntryType; +} + +/** + * UpdateEntryLock Use Case - Updates lock timestamp to keep it alive + */ +export interface IUpdateEntryLockUseCase { + execute(input: UpdateEntryLockInput): Promise>; +} + +export interface IUpdateEntryLockUseCaseErrors { + notFound: LockRecordNotFoundError; + notSameIdentity: NotSameIdentityError; + persistence: LockRecordPersistenceError; + updateError: UpdateEntryLockError; +} + +type UseCaseError = IUpdateEntryLockUseCaseErrors[keyof IUpdateEntryLockUseCaseErrors]; + +export const UpdateEntryLockUseCase = createAbstraction("UpdateEntryLockUseCase"); + +export namespace UpdateEntryLockUseCase { + export type Interface = IUpdateEntryLockUseCase; + export type Error = UseCaseError; +} + +/** + * UpdateEntryLockRepository - Updates lock record in storage + */ +export interface IUpdateEntryLockRepository { + update(lockRecordId: string, updateOwner: boolean): Promise>; +} + +export interface IUpdateEntryLockRepositoryErrors { + persistence: LockRecordPersistenceError; +} + +type RepositoryError = IUpdateEntryLockRepositoryErrors[keyof IUpdateEntryLockRepositoryErrors]; + +export const UpdateEntryLockRepository = createAbstraction("UpdateEntryLockRepository"); + +export namespace UpdateEntryLockRepository { + export type Interface = IUpdateEntryLockRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/feature.ts b/packages/api-record-locking/src/features/UpdateEntryLock/feature.ts new file mode 100644 index 00000000000..99d847cbdb4 --- /dev/null +++ b/packages/api-record-locking/src/features/UpdateEntryLock/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateEntryLockUseCase } from "./UpdateEntryLockUseCase.js"; +import { UpdateEntryLockRepository } from "./UpdateEntryLockRepository.js"; + +export const UpdateEntryLockFeature = createFeature({ + name: "UpdateEntryLock", + register(container) { + container.register(UpdateEntryLockUseCase); + container.register(UpdateEntryLockRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/index.ts b/packages/api-record-locking/src/features/UpdateEntryLock/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-record-locking/src/features/UpdateEntryLock/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index d1842f5f3cf..5c632457a4c 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -7,6 +7,8 @@ import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; import { RecordLockingConfig, RecordLockingModel } from "~/domain/index.js"; import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; import { RECORD_LOCKING_MODEL_ID } from "~/domain/model.js"; +import { getTimeout } from "~/utils/getTimeout.js"; +import { RecordLockingFeature } from "~/features/RecordLockingFeature.js"; export interface ICreateContextPluginParams { /** @@ -26,9 +28,11 @@ const createContextPlugin = (params?: ICreateContextPluginParams) => { context.plugins.register(createLockingModel()); + const timeout = getTimeout(params?.timeout); + context.recordLocking = await createRecordLockingCrud({ context, - timeout: params?.timeout + timeout }); const graphQlPlugin = await createGraphQLSchema({ context }); @@ -37,10 +41,10 @@ const createContextPlugin = (params?: ICreateContextPluginParams) => { const recordLockingModel = await getModel.execute(RECORD_LOCKING_MODEL_ID); - // Register new abstractions - context.container.registerInstance(RecordLockingModel, recordLockingModel.value); - context.container.registerInstance(RecordLockingConfig, { - timeout: params?.timeout + // Register features + RecordLockingFeature.register(context.container, { + timeout, + model: recordLockingModel.value }); }); plugin.name = "context.recordLocking"; diff --git a/packages/api-record-locking/src/utils/hasFullAccessPermission.ts b/packages/api-record-locking/src/utils/hasFullAccessPermission.ts new file mode 100644 index 00000000000..14082b792d7 --- /dev/null +++ b/packages/api-record-locking/src/utils/hasFullAccessPermission.ts @@ -0,0 +1,19 @@ +import type { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import type { SecurityPermission } from "@webiny/api-core/types/security.js"; + +interface RecordLockingSecurityPermission extends SecurityPermission { + canForceUnlock?: string; +} + +export const hasFullAccessPermission = async ( + identityContext: IdentityContext.Interface +): Promise => { + const hasFullAccess = await identityContext.hasFullAccess(); + if (hasFullAccess) { + return true; + } + + const permission = + await identityContext.getPermission("recordLocking"); + return permission?.canForceUnlock === "yes"; +}; From be44525cd91481a2339d7903e5732c6d0b5816df Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 12:32:44 +0100 Subject: [PATCH 47/71] wip: migrate record locking --- .../api-file-manager-s3/tsconfig.build.json | 3 + packages/api-file-manager-s3/tsconfig.json | 3 + .../tsconfig.build.json | 6 +- .../api-headless-cms-scheduler/tsconfig.json | 6 +- .../src/abstractions/IGetLockRecordUseCase.ts | 11 - .../IGetLockedEntryLockRecordUseCase.ts | 13 - .../src/abstractions/IIsEntryLocked.ts | 11 - .../IKickOutCurrentUserUseCase.ts | 7 - .../IListAllLockRecordsUseCase.ts | 18 -- .../abstractions/IListLockRecordsUseCase.ts | 14 - .../src/abstractions/ILockEntryUseCase.ts | 14 - .../IUnlockEntryRequestUseCase.ts | 14 - .../src/abstractions/IUnlockEntryUseCase.ts | 15 - .../abstractions/IUpdateEntryLockUseCase.ts | 14 - packages/api-record-locking/src/crud/crud.ts | 267 ------------------ packages/api-record-locking/src/crud/model.ts | 150 ---------- .../src/domain/LockRecord.ts | 4 +- .../src/domain/calculateExpiresOn.ts | 8 + .../api-record-locking/src/domain/errors.ts | 32 +++ .../GetLockedEntryLockRecordUseCase.ts | 48 ++++ .../GetLockedEntryLockRecord/abstractions.ts | 33 +++ .../GetLockedEntryLockRecord/feature.ts | 9 + .../GetLockedEntryLockRecord/index.ts | 2 + .../ListAllLockRecordsRepository.ts | 54 ++++ .../ListAllLockRecordsUseCase.ts | 20 ++ .../ListAllLockRecords/abstractions.ts | 53 ++++ .../features/ListAllLockRecords/feature.ts | 11 + .../src/features/ListAllLockRecords/index.ts | 2 + .../LockEntry/LockEntryEventsDecorator.ts | 57 ++++ .../features/LockEntry/LockEntryUseCase.ts | 1 - .../src/features/LockEntry/events.ts | 85 ++++++ .../src/features/LockEntry/feature.ts | 2 + .../src/features/RecordLockingFeature.ts | 6 + .../UnlockEntry/UnlockEntryEventsDecorator.ts | 59 ++++ .../UnlockEntry/UnlockEntryUseCase.ts | 27 +- .../src/features/UnlockEntry/events.ts | 86 ++++++ .../src/features/UnlockEntry/feature.ts | 2 + .../UnlockEntry}/hasFullAccessPermission.ts | 0 .../UnlockEntryRequestEventsDecorator.ts | 64 +++++ .../UnlockEntryRequestRepository.ts | 45 +++ .../UnlockEntryRequestUseCase.ts | 102 +++++++ .../UnlockEntryRequest/abstractions.ts | 59 ++++ .../src/features/UnlockEntryRequest/events.ts | 85 ++++++ .../features/UnlockEntryRequest/feature.ts | 13 + .../src/features/UnlockEntryRequest/index.ts | 3 + .../src/graphql/checkPermissions.ts | 15 + .../src/{utils => graphql}/resolve.ts | 0 .../api-record-locking/src/graphql/schema.ts | 115 +++++--- packages/api-record-locking/src/index.ts | 32 ++- packages/api-record-locking/src/types.ts | 161 ----------- .../GetLockRecord/GetLockRecordUseCase.ts | 51 ---- .../GetLockedEntryLockRecordUseCase.ts | 38 --- .../IsEntryLocked/IsEntryLockedUseCase.ts | 39 --- .../KickOutCurrentUserUseCase.ts | 53 ---- .../ListAllLockRecordsUseCase.ts | 44 --- .../ListLockRecordsUseCase.ts | 37 --- .../LockEntryUseCase/LockEntryUseCase.ts | 91 ------ .../UnlockEntryUseCase/UnlockEntryUseCase.ts | 121 -------- .../UnlockEntryRequestUseCase.ts | 114 -------- .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 133 --------- .../api-record-locking/src/useCases/index.ts | 136 --------- .../api-record-locking/src/useCases/types.ts | 6 - .../src/utils/calculateExpiresOn.ts | 13 - .../src/utils/checkPermissions.ts | 14 - .../src/utils/convertEntryToLockRecord.ts | 135 --------- .../api-record-locking/src/utils/errors.ts | 19 -- .../src/utils/validateSameIdentity.ts | 17 -- .../api-record-locking/tsconfig.build.json | 3 + packages/api-record-locking/tsconfig.json | 3 + packages/api-scheduler/tsconfig.build.json | 9 +- packages/api-scheduler/tsconfig.json | 9 +- packages/api-websockets/src/index.ts | 1 - packages/project-aws/tsconfig.build.json | 3 + packages/project-aws/tsconfig.json | 3 + packages/testing/tsconfig.build.json | 3 + packages/testing/tsconfig.json | 3 + 76 files changed, 1112 insertions(+), 1847 deletions(-) delete mode 100644 packages/api-record-locking/src/abstractions/IGetLockRecordUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IGetLockedEntryLockRecordUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IIsEntryLocked.ts delete mode 100644 packages/api-record-locking/src/abstractions/IKickOutCurrentUserUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IListAllLockRecordsUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IListLockRecordsUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/ILockEntryUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IUnlockEntryRequestUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IUnlockEntryUseCase.ts delete mode 100644 packages/api-record-locking/src/abstractions/IUpdateEntryLockUseCase.ts delete mode 100644 packages/api-record-locking/src/crud/crud.ts delete mode 100644 packages/api-record-locking/src/crud/model.ts create mode 100644 packages/api-record-locking/src/domain/calculateExpiresOn.ts create mode 100644 packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts create mode 100644 packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts create mode 100644 packages/api-record-locking/src/features/GetLockedEntryLockRecord/feature.ts create mode 100644 packages/api-record-locking/src/features/GetLockedEntryLockRecord/index.ts create mode 100644 packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsRepository.ts create mode 100644 packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts create mode 100644 packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts create mode 100644 packages/api-record-locking/src/features/ListAllLockRecords/feature.ts create mode 100644 packages/api-record-locking/src/features/ListAllLockRecords/index.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/LockEntryEventsDecorator.ts create mode 100644 packages/api-record-locking/src/features/LockEntry/events.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/UnlockEntryEventsDecorator.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntry/events.ts rename packages/api-record-locking/src/{utils => features/UnlockEntry}/hasFullAccessPermission.ts (100%) create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestEventsDecorator.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestRepository.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestUseCase.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/events.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/feature.ts create mode 100644 packages/api-record-locking/src/features/UnlockEntryRequest/index.ts create mode 100644 packages/api-record-locking/src/graphql/checkPermissions.ts rename packages/api-record-locking/src/{utils => graphql}/resolve.ts (100%) delete mode 100644 packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts delete mode 100644 packages/api-record-locking/src/useCases/index.ts delete mode 100644 packages/api-record-locking/src/useCases/types.ts delete mode 100644 packages/api-record-locking/src/utils/calculateExpiresOn.ts delete mode 100644 packages/api-record-locking/src/utils/checkPermissions.ts delete mode 100644 packages/api-record-locking/src/utils/convertEntryToLockRecord.ts delete mode 100644 packages/api-record-locking/src/utils/errors.ts delete mode 100644 packages/api-record-locking/src/utils/validateSameIdentity.ts diff --git a/packages/api-file-manager-s3/tsconfig.build.json b/packages/api-file-manager-s3/tsconfig.build.json index c5512fe51e5..c95964e039a 100644 --- a/packages/api-file-manager-s3/tsconfig.build.json +++ b/packages/api-file-manager-s3/tsconfig.build.json @@ -153,6 +153,9 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/packages/api-file-manager-s3/tsconfig.json b/packages/api-file-manager-s3/tsconfig.json index bf228d34ad1..61db72583c8 100644 --- a/packages/api-file-manager-s3/tsconfig.json +++ b/packages/api-file-manager-s3/tsconfig.json @@ -153,6 +153,9 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/packages/api-headless-cms-scheduler/tsconfig.build.json b/packages/api-headless-cms-scheduler/tsconfig.build.json index 62c81c60ff4..cc1f5a4c678 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.build.json +++ b/packages/api-headless-cms-scheduler/tsconfig.build.json @@ -5,11 +5,11 @@ { "path": "../api/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../api-scheduler/tsconfig.build.json" }, - { "path": "../error/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, { "path": "../api-core/tsconfig.build.json" }, + { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, @@ -43,8 +43,6 @@ ], "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], "@webiny/api-scheduler": ["../api-scheduler/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], "@webiny/feature/admin": ["../feature/src/admin/index.js"], "@webiny/feature/*": ["../feature/src/*"], @@ -179,6 +177,8 @@ ], "@webiny/api-core/*": ["../api-core/src/*"], "@webiny/api-core": ["../api-core/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-headless-cms-scheduler/tsconfig.json b/packages/api-headless-cms-scheduler/tsconfig.json index c07721e66f2..cd04c4b3a57 100644 --- a/packages/api-headless-cms-scheduler/tsconfig.json +++ b/packages/api-headless-cms-scheduler/tsconfig.json @@ -5,11 +5,11 @@ { "path": "../api" }, { "path": "../api-headless-cms" }, { "path": "../api-scheduler" }, - { "path": "../error" }, { "path": "../feature" }, { "path": "../handler-graphql" }, { "path": "../utils" }, { "path": "../api-core" }, + { "path": "../aws-sdk" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../plugins" }, @@ -43,8 +43,6 @@ ], "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], "@webiny/api-scheduler": ["../api-scheduler/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], "@webiny/feature/admin": ["../feature/src/admin/index.js"], "@webiny/feature/*": ["../feature/src/*"], @@ -179,6 +177,8 @@ ], "@webiny/api-core/*": ["../api-core/src/*"], "@webiny/api-core": ["../api-core/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-record-locking/src/abstractions/IGetLockRecordUseCase.ts b/packages/api-record-locking/src/abstractions/IGetLockRecordUseCase.ts deleted file mode 100644 index b174ab3621d..00000000000 --- a/packages/api-record-locking/src/abstractions/IGetLockRecordUseCase.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IRecordLockingGetLockRecordParams, IRecordLockingLockRecord } from "~/types.js"; - -export type IGetLockRecordUseCaseExecuteParams = IRecordLockingGetLockRecordParams; - -export interface IGetLockRecordUseCaseExecute { - (params: IGetLockRecordUseCaseExecuteParams): Promise; -} - -export interface IGetLockRecordUseCase { - execute: IGetLockRecordUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IGetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/abstractions/IGetLockedEntryLockRecordUseCase.ts deleted file mode 100644 index da7b73b9f55..00000000000 --- a/packages/api-record-locking/src/abstractions/IGetLockedEntryLockRecordUseCase.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IRecordLockingIsLockedParams, IRecordLockingLockRecord } from "~/types.js"; - -export type IGetLockedEntryLockRecordUseCaseExecuteParams = IRecordLockingIsLockedParams; - -export interface IGetLockedEntryLockRecordUseCaseExecute { - ( - params: IGetLockedEntryLockRecordUseCaseExecuteParams - ): Promise; -} - -export interface IGetLockedEntryLockRecordUseCase { - execute: IGetLockedEntryLockRecordUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IIsEntryLocked.ts b/packages/api-record-locking/src/abstractions/IIsEntryLocked.ts deleted file mode 100644 index 6152251188d..00000000000 --- a/packages/api-record-locking/src/abstractions/IIsEntryLocked.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IRecordLockingIsLockedParams } from "~/types.js"; - -export type IIsEntryLockedUseCaseExecuteParams = IRecordLockingIsLockedParams; - -export interface IIsEntryLockedUseCaseExecute { - (params: IIsEntryLockedUseCaseExecuteParams): Promise; -} - -export interface IIsEntryLockedUseCase { - execute: IIsEntryLockedUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IKickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/abstractions/IKickOutCurrentUserUseCase.ts deleted file mode 100644 index 74cb3dd9605..00000000000 --- a/packages/api-record-locking/src/abstractions/IKickOutCurrentUserUseCase.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { IRecordLockingLockRecord } from "~/types.js"; - -export type IKickOutCurrentUserUseCaseExecuteParams = IRecordLockingLockRecord; - -export interface IKickOutCurrentUserUseCase { - execute(params: IKickOutCurrentUserUseCaseExecuteParams): Promise; -} diff --git a/packages/api-record-locking/src/abstractions/IListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/abstractions/IListAllLockRecordsUseCase.ts deleted file mode 100644 index 231751bdb31..00000000000 --- a/packages/api-record-locking/src/abstractions/IListAllLockRecordsUseCase.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { - IRecordLockingListAllLockRecordsParams, - IRecordLockingListAllLockRecordsResponse -} from "~/types.js"; - -export type IListAllLockRecordsUseCaseExecuteParams = IRecordLockingListAllLockRecordsParams; - -export type IListAllLockRecordsUseCaseExecuteResponse = IRecordLockingListAllLockRecordsResponse; - -export interface IListAllLockRecordsUseCaseExecute { - ( - params: IListAllLockRecordsUseCaseExecuteParams - ): Promise; -} - -export interface IListAllLockRecordsUseCase { - execute: IListAllLockRecordsUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IListLockRecordsUseCase.ts b/packages/api-record-locking/src/abstractions/IListLockRecordsUseCase.ts deleted file mode 100644 index aa9d526752d..00000000000 --- a/packages/api-record-locking/src/abstractions/IListLockRecordsUseCase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IListAllLockRecordsUseCaseExecuteParams } from "./IListAllLockRecordsUseCase.js"; -import type { IRecordLockingListAllLockRecordsResponse } from "~/types.js"; - -export type IListLockRecordsUseCaseExecuteParams = IListAllLockRecordsUseCaseExecuteParams; - -export type IListLockRecordsUseCaseExecuteResponse = IRecordLockingListAllLockRecordsResponse; - -export interface IListLockRecordsUseCaseExecute { - (params: IListLockRecordsUseCaseExecuteParams): Promise; -} - -export interface IListLockRecordsUseCase { - execute: IListLockRecordsUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/ILockEntryUseCase.ts b/packages/api-record-locking/src/abstractions/ILockEntryUseCase.ts deleted file mode 100644 index bc4ec9866f5..00000000000 --- a/packages/api-record-locking/src/abstractions/ILockEntryUseCase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IRecordLockingLockRecord, IRecordLockingLockRecordEntryType } from "~/types.js"; - -export interface ILockEntryUseCaseExecuteParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface ILockEntryUseCaseExecute { - (params: ILockEntryUseCaseExecuteParams): Promise; -} - -export interface ILockEntryUseCase { - execute: ILockEntryUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IUnlockEntryRequestUseCase.ts b/packages/api-record-locking/src/abstractions/IUnlockEntryRequestUseCase.ts deleted file mode 100644 index 220124f6de0..00000000000 --- a/packages/api-record-locking/src/abstractions/IUnlockEntryRequestUseCase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IRecordLockingLockRecord, IRecordLockingLockRecordEntryType } from "~/types.js"; - -export interface IUnlockEntryRequestUseCaseExecuteParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IUnlockEntryRequestUseCaseExecute { - (params: IUnlockEntryRequestUseCaseExecuteParams): Promise; -} - -export interface IUnlockEntryRequestUseCase { - execute: IUnlockEntryRequestUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IUnlockEntryUseCase.ts b/packages/api-record-locking/src/abstractions/IUnlockEntryUseCase.ts deleted file mode 100644 index b54d9c7d825..00000000000 --- a/packages/api-record-locking/src/abstractions/IUnlockEntryUseCase.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { IRecordLockingLockRecord, IRecordLockingLockRecordEntryType } from "~/types.js"; - -export interface IUnlockEntryUseCaseExecuteParams { - id: string; - type: IRecordLockingLockRecordEntryType; - force?: boolean; -} - -export interface IUnlockEntryUseCaseExecute { - (params: IUnlockEntryUseCaseExecuteParams): Promise; -} - -export interface IUnlockEntryUseCase { - execute: IUnlockEntryUseCaseExecute; -} diff --git a/packages/api-record-locking/src/abstractions/IUpdateEntryLockUseCase.ts b/packages/api-record-locking/src/abstractions/IUpdateEntryLockUseCase.ts deleted file mode 100644 index 88fee8503a4..00000000000 --- a/packages/api-record-locking/src/abstractions/IUpdateEntryLockUseCase.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { IRecordLockingLockRecord, IRecordLockingLockRecordEntryType } from "~/types.js"; - -export interface IUpdateEntryLockUseCaseExecuteParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IUpdateEntryLockUseCaseExecute { - (params: IUpdateEntryLockUseCaseExecuteParams): Promise; -} - -export interface IUpdateEntryLockUseCase { - execute: IUpdateEntryLockUseCaseExecute; -} diff --git a/packages/api-record-locking/src/crud/crud.ts b/packages/api-record-locking/src/crud/crud.ts deleted file mode 100644 index 8dd3ffe075e..00000000000 --- a/packages/api-record-locking/src/crud/crud.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { WebinyError } from "@webiny/error"; -import type { - Context, - IGetIdentity, - IGetWebsocketsContextCallable, - IHasRecordLockingAccessCallable, - IRecordLocking, - IRecordLockingLockRecordValues, - IRecordLockingModelManager, - OnEntryAfterLockTopicParams, - OnEntryAfterUnlockRequestTopicParams, - OnEntryAfterUnlockTopicParams, - OnEntryBeforeLockTopicParams, - OnEntryBeforeUnlockRequestTopicParams, - OnEntryBeforeUnlockTopicParams, - OnEntryLockErrorTopicParams, - OnEntryUnlockErrorTopicParams, - OnEntryUnlockRequestErrorTopicParams, - RecordLockingSecurityPermission -} from "~/types.js"; -import { RECORD_LOCKING_MODEL_ID } from "./model.js"; -import type { IGetLockRecordUseCaseExecute } from "~/abstractions/IGetLockRecordUseCase.js"; -import type { IIsEntryLockedUseCaseExecute } from "~/abstractions/IIsEntryLocked.js"; -import type { ILockEntryUseCaseExecute } from "~/abstractions/ILockEntryUseCase.js"; -import type { IUnlockEntryUseCaseExecute } from "~/abstractions/IUnlockEntryUseCase.js"; -import { createUseCases } from "~/useCases/index.js"; -import type { IUnlockEntryRequestUseCaseExecute } from "~/abstractions/IUnlockEntryRequestUseCase.js"; -import { createTopic } from "@webiny/pubsub"; -import type { IListAllLockRecordsUseCaseExecute } from "~/abstractions/IListAllLockRecordsUseCase.js"; -import type { IListLockRecordsUseCaseExecute } from "~/abstractions/IListLockRecordsUseCase.js"; -import type { IUpdateEntryLockUseCaseExecute } from "~/abstractions/IUpdateEntryLockUseCase.js"; -import type { IGetLockedEntryLockRecordUseCaseExecute } from "~/abstractions/IGetLockedEntryLockRecordUseCase.js"; -import { getTimeout as baseGetTimeout } from "~/utils/getTimeout.js"; - -interface Params { - context: Pick; - timeout?: number; -} - -export const createRecordLockingCrud = async (params: Params): Promise => { - const { context } = params; - const getTimeout = (): number => { - return baseGetTimeout(params.timeout); - }; - const getModel = async () => { - const model = await context.cms.getModel(RECORD_LOCKING_MODEL_ID); - if (model) { - return model; - } - throw new WebinyError("Record Locking model not found.", "MODEL_NOT_FOUND", { - modelId: RECORD_LOCKING_MODEL_ID - }); - }; - - const getManager = async (): Promise => { - return await context.cms.getEntryManager( - RECORD_LOCKING_MODEL_ID - ); - }; - - const getSecurity = () => { - return context.security; - }; - - const getIdentity: IGetIdentity = () => { - const identity = context.security.getIdentity(); - if (!identity) { - throw new WebinyError("Identity missing."); - } - return { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }; - }; - - const hasRecordLockingAccess: IHasRecordLockingAccessCallable = async () => { - const hasFulLAccess = await context.security.hasFullAccess(); - if (hasFulLAccess) { - return true; - } - const permission = - await context.security.getPermission("recordLocking"); - return permission?.canForceUnlock === "yes"; - }; - - const onEntryBeforeLock = createTopic( - "cms.recordLocking.onEntryBeforeLock" - ); - const onEntryAfterLock = createTopic( - "cms.recordLocking.onEntryAfterLock" - ); - const onEntryLockError = createTopic( - "cms.recordLocking.onEntryLockError" - ); - - const onEntryBeforeUnlock = createTopic( - "cms.recordLocking.onEntryBeforeUnlock" - ); - const onEntryAfterUnlock = createTopic( - "cms.recordLocking.onEntryAfterUnlock" - ); - const onEntryUnlockError = createTopic( - "cms.recordLocking.onEntryUnlockError" - ); - - const onEntryBeforeUnlockRequest = createTopic( - "cms.recordLocking.onEntryBeforeUnlockRequest" - ); - const onEntryAfterUnlockRequest = createTopic( - "cms.recordLocking.onEntryAfterUnlockRequest" - ); - const onEntryUnlockRequestError = createTopic( - "cms.recordLocking.onEntryUnlockRequestError" - ); - - const getWebsockets: IGetWebsocketsContextCallable = () => { - return context.websockets; - }; - - const { - listLockRecordsUseCase, - listAllLockRecordsUseCase, - getLockRecordUseCase, - isEntryLockedUseCase, - getLockedEntryLockRecordUseCase, - lockEntryUseCase, - updateEntryLockUseCase, - unlockEntryUseCase, - unlockEntryRequestUseCase - } = createUseCases({ - getIdentity, - getManager, - getSecurity, - hasRecordLockingAccess, - getWebsockets, - getTimeout - }); - - const listAllLockRecords: IListAllLockRecordsUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.listAllLockRecords", async () => { - return listAllLockRecordsUseCase.execute(params); - }); - }; - - const listLockRecords: IListLockRecordsUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.listLockRecords", async () => { - return listLockRecordsUseCase.execute(params); - }); - }; - - const getLockRecord: IGetLockRecordUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.getLockRecord", async () => { - return getLockRecordUseCase.execute(params); - }); - }; - - const isEntryLocked: IIsEntryLockedUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.isEntryLocked", async () => { - return isEntryLockedUseCase.execute(params); - }); - }; - - const getLockedEntryLockRecord: IGetLockedEntryLockRecordUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.getLockedEntryLockRecord", async () => { - return getLockedEntryLockRecordUseCase.execute(params); - }); - }; - - const lockEntry: ILockEntryUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.lockEntry", async () => { - try { - await onEntryBeforeLock.publish(params); - const record = await lockEntryUseCase.execute(params); - await onEntryAfterLock.publish({ - ...params, - record - }); - return record; - } catch (ex) { - await onEntryLockError.publish({ - ...params, - error: ex - }); - throw ex; - } - }); - }; - - const updateEntryLock: IUpdateEntryLockUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.updateEntryLock", async () => { - return updateEntryLockUseCase.execute(params); - }); - }; - - const unlockEntry: IUnlockEntryUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.unlockEntry", async () => { - try { - await onEntryBeforeUnlock.publish({ - ...params, - getIdentity - }); - const record = await unlockEntryUseCase.execute(params); - await onEntryAfterUnlock.publish({ - ...params, - record - }); - return record; - } catch (ex) { - await onEntryUnlockError.publish({ - ...params, - error: ex - }); - throw ex; - } - }); - }; - - const unlockEntryRequest: IUnlockEntryRequestUseCaseExecute = async params => { - return context.benchmark.measure("recordLocking.unlockEntryRequest", async () => { - try { - await onEntryBeforeUnlockRequest.publish(params); - const record = await unlockEntryRequestUseCase.execute(params); - await onEntryAfterUnlockRequest.publish({ - ...params, - record - }); - return record; - } catch (ex) { - await onEntryUnlockRequestError.publish({ - ...params, - error: ex - }); - throw ex; - } - }); - }; - - return { - /** - * Lifecycle events - */ - onEntryBeforeLock, - onEntryAfterLock, - onEntryLockError, - onEntryBeforeUnlock, - onEntryAfterUnlock, - onEntryUnlockError, - onEntryBeforeUnlockRequest, - onEntryAfterUnlockRequest, - onEntryUnlockRequestError, - /** - * Methods - */ - getModel, - listLockRecords, - listAllLockRecords, - getLockRecord, - isEntryLocked, - getLockedEntryLockRecord, - lockEntry, - updateEntryLock, - unlockEntry, - unlockEntryRequest, - getTimeout - }; -}; diff --git a/packages/api-record-locking/src/crud/model.ts b/packages/api-record-locking/src/crud/model.ts deleted file mode 100644 index 0e58ade37e3..00000000000 --- a/packages/api-record-locking/src/crud/model.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { createCmsModel, createPrivateModel } from "@webiny/api-headless-cms"; - -export const RECORD_LOCKING_MODEL_ID = "wby_recordLocking"; - -export const createLockingModel = () => { - return createCmsModel( - createPrivateModel({ - modelId: RECORD_LOCKING_MODEL_ID, - name: "Record Lock Tracking", - fields: [ - { - id: "targetId", - type: "text", - fieldId: "targetId", - storageId: "text@targetId", - label: "Target ID", - validation: [ - { - name: "required", - message: "Target ID is required." - } - ] - }, - /** - * Since we need a generic way to track records, we will use type to determine if it's a cms record or a page or a form, etc... - * Update IHeadlessCmsLockRecordValues in types.ts file with additional fields as required. - * - * @see IHeadlessCmsLockRecordValues - */ - { - id: "type", - type: "text", - fieldId: "type", - storageId: "text@type", - label: "Record Type", - validation: [ - { - name: "required", - message: "Record type is required." - } - ] - }, - { - id: "actions", - type: "object", - fieldId: "actions", - storageId: "object@actions", - label: "Actions", - multipleValues: true, - settings: { - fields: [ - { - id: "type", - type: "text", - fieldId: "type", - storageId: "text@type", - label: "Action Type", - validation: [ - { - name: "required", - message: "Action type is required." - } - ] - }, - { - id: "message", - type: "text", - fieldId: "message", - storageId: "text@message", - label: "Message" - }, - { - id: "createdBy", - type: "object", - fieldId: "createdBy", - storageId: "object@createdBy", - label: "Created By", - validation: [ - { - name: "required", - message: "Created by is required." - } - ], - settings: { - fields: [ - { - id: "id", - type: "text", - fieldId: "id", - storageId: "text@id", - label: "ID", - validation: [ - { - name: "required", - message: "ID is required." - } - ] - }, - { - id: "displayName", - type: "text", - fieldId: "displayName", - storageId: "text@displayName", - label: "Display Name", - validation: [ - { - name: "required", - message: "Display name is required." - } - ] - }, - { - id: "type", - type: "text", - fieldId: "type", - storageId: "text@type", - label: "Type", - validation: [ - { - name: "required", - message: "Type is required." - } - ] - } - ] - } - }, - { - id: "createdOn", - type: "datetime", - fieldId: "createdOn", - storageId: "datetime@createdOn", - settings: { - type: "dateTimeWithoutTimezone" - }, - label: "Created On", - validation: [ - { - name: "required", - message: "Created on is required." - } - ] - } - ] - } - } - ] - }) - ); -}; diff --git a/packages/api-record-locking/src/domain/LockRecord.ts b/packages/api-record-locking/src/domain/LockRecord.ts index b44d048379e..b2f01d0685b 100644 --- a/packages/api-record-locking/src/domain/LockRecord.ts +++ b/packages/api-record-locking/src/domain/LockRecord.ts @@ -11,7 +11,7 @@ import type { LockRecordValues } from "./types.js"; import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId.js"; -import { calculateExpiresOn } from "~/utils/calculateExpiresOn.js"; +import { calculateExpiresOn } from "./calculateExpiresOn.js"; export interface ILockRecord { readonly id: string; @@ -85,7 +85,7 @@ export class LockRecord implements ILockRecord { this._lockedBy = input.createdBy; this._lockedOn = new Date(input.createdOn); this._updatedOn = new Date(input.savedOn); - this._expiresOn = calculateExpiresOn(input, timeout); + this._expiresOn = calculateExpiresOn(input.savedOn, timeout); this._actions = input.values.actions; } diff --git a/packages/api-record-locking/src/domain/calculateExpiresOn.ts b/packages/api-record-locking/src/domain/calculateExpiresOn.ts new file mode 100644 index 00000000000..4e94e8c986c --- /dev/null +++ b/packages/api-record-locking/src/domain/calculateExpiresOn.ts @@ -0,0 +1,8 @@ +export const calculateExpiresOn = (savedOn: string | undefined, timeout: number): Date => { + if (!savedOn) { + throw new Error("Missing savedOn property."); + } + const savedOnDate = new Date(savedOn); + + return new Date(savedOnDate.getTime() + timeout); +}; diff --git a/packages/api-record-locking/src/domain/errors.ts b/packages/api-record-locking/src/domain/errors.ts index 4296a440166..d7423fccff7 100644 --- a/packages/api-record-locking/src/domain/errors.ts +++ b/packages/api-record-locking/src/domain/errors.ts @@ -82,3 +82,35 @@ export class IdentityMissingError extends BaseError { }); } } + +export class EntryNotLockedError extends BaseError<{ id: string; type: string }> { + override readonly code = "RecordLocking/Entry/NotLockedError" as const; + + constructor(data: { id: string; type: string }) { + super({ + message: "Entry is not locked.", + data + }); + } +} + +export class UnlockRequestAlreadySentError extends BaseError<{ id: string; type: string; identityId: string }> { + override readonly code = "RecordLocking/Entry/UnlockRequestAlreadySentError" as const; + + constructor(data: { id: string; type: string; identityId: string }) { + super({ + message: "Unlock request already sent.", + data + }); + } +} + +export class UnlockEntryRequestError extends BaseError { + override readonly code = "RecordLocking/Entry/UnlockRequestError" as const; + + constructor(error: Error) { + super({ + message: `Could not request unlock: ${error.message}` + }); + } +} diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts new file mode 100644 index 00000000000..c3c6259cde5 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts @@ -0,0 +1,48 @@ +import { Result } from "@webiny/feature/api"; +import { + GetLockedEntryLockRecordUseCase as UseCaseAbstraction, + GetLockedEntryLockRecordInput +} from "./abstractions.js"; +import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { LockRecordNotFoundError } from "~/domain/errors.js"; + +class GetLockedEntryLockRecordUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getLockRecord: GetLockRecordUseCase.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute( + input: GetLockedEntryLockRecordInput + ): Promise> { + // Get the lock record + const result = await this.getLockRecord.execute(input); + + // If not found or error, return not found error + if (result.isFail()) { + return Result.fail(new LockRecordNotFoundError({ id: input.id })); + } + + const record = result.value; + const identity = this.identityContext.getIdentity(); + + // Record is treated as "not found": + // - If locked by current user + // - If expired + const lockedByCurrentUser = record.lockedBy.id === identity.id; + + if (record.isExpired() || lockedByCurrentUser) { + return Result.fail(new LockRecordNotFoundError({ id: input.id })); + } + + // Locked by another user, return the record + return Result.ok(record); + } +} + +export const GetLockedEntryLockRecordUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetLockedEntryLockRecordUseCaseImpl, + dependencies: [GetLockRecordUseCase, IdentityContext] +}); diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts new file mode 100644 index 00000000000..d6388ef26af --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts @@ -0,0 +1,33 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import type { LockRecordNotFoundError } from "~/domain/errors.js"; + +// Input type +export interface GetLockedEntryLockRecordInput { + id: string; + type: LockRecordEntryType; +} + +/** + * GetLockedEntryLockRecord Use Case + * Returns lock record ONLY if entry is locked by someone OTHER than current user + * Returns error if: not found, expired, or locked by current user + */ +export interface IGetLockedEntryLockRecordUseCase { + execute(input: GetLockedEntryLockRecordInput): Promise>; +} + +export interface IGetLockedEntryLockRecordUseCaseErrors { + notFound: LockRecordNotFoundError; +} + +type UseCaseError = IGetLockedEntryLockRecordUseCaseErrors[keyof IGetLockedEntryLockRecordUseCaseErrors]; + +export const GetLockedEntryLockRecordUseCase = createAbstraction("GetLockedEntryLockRecordUseCase"); + +export namespace GetLockedEntryLockRecordUseCase { + export type Interface = IGetLockedEntryLockRecordUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/feature.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/feature.ts new file mode 100644 index 00000000000..43a4bb517e2 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetLockedEntryLockRecordUseCase } from "./GetLockedEntryLockRecordUseCase.js"; + +export const GetLockedEntryLockRecordFeature = createFeature({ + name: "GetLockedEntryLockRecord", + register(container) { + container.register(GetLockedEntryLockRecordUseCase); + } +}); diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/index.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/index.ts new file mode 100644 index 00000000000..6448384b9a1 --- /dev/null +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./feature.js"; diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsRepository.ts b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsRepository.ts new file mode 100644 index 00000000000..0a6ab7bdb59 --- /dev/null +++ b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsRepository.ts @@ -0,0 +1,54 @@ +import { Result } from "@webiny/feature/api"; +import { + ListAllLockRecordsRepository as RepositoryAbstraction, + ListAllLockRecordsInput, + ListAllLockRecordsOutput +} from "./abstractions.js"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; +import { LockRecordPersistenceError } from "~/domain/errors.js"; +import { convertWhereCondition } from "~/utils/convertWhereCondition.js"; +import { LockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordValues } from "~/domain/index.js"; + +class ListAllLockRecordsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private config: RecordLockingConfig.Interface, + private listEntries: ListLatestEntriesUseCase.Interface, + private model: CmsModel + ) {} + + async execute( + input?: ListAllLockRecordsInput + ): Promise> { + try { + const params = { + ...input, + where: convertWhereCondition(input?.where) + }; + + const result = await this.listEntries.execute(this.model, params); + + if (result.isFail()) { + return Result.fail(new LockRecordPersistenceError(result.error)); + } + + const [data, meta] = result.value; + + const items = data.map(entry => new LockRecord(entry, this.config.timeout)); + + return Result.ok({ + items, + meta + }); + } catch (error) { + return Result.fail(new LockRecordPersistenceError(error as Error)); + } + } +} + +export const ListAllLockRecordsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListAllLockRecordsRepositoryImpl, + dependencies: [RecordLockingConfig, ListLatestEntriesUseCase, RecordLockingModel] +}); diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts new file mode 100644 index 00000000000..240dc384ec6 --- /dev/null +++ b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts @@ -0,0 +1,20 @@ +import { Result } from "@webiny/feature/api"; +import { + ListAllLockRecordsUseCase as UseCaseAbstraction, + ListAllLockRecordsRepository, + ListAllLockRecordsInput, + ListAllLockRecordsOutput +} from "./abstractions.js"; + +class ListAllLockRecordsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor(private repository: ListAllLockRecordsRepository.Interface) {} + + async execute(input?: ListAllLockRecordsInput): Promise> { + return await this.repository.execute(input); + } +} + +export const ListAllLockRecordsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListAllLockRecordsUseCaseImpl, + dependencies: [ListAllLockRecordsRepository] +}); diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts b/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts new file mode 100644 index 00000000000..19abf5f95f4 --- /dev/null +++ b/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts @@ -0,0 +1,53 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordPersistenceError } from "~/domain/errors.js"; +import type { CmsEntryListParams, CmsEntryMeta } from "@webiny/api-headless-cms/types"; + +// Input/Output types +export type ListAllLockRecordsInput = Pick; + +export interface ListAllLockRecordsOutput { + items: ILockRecord[]; + meta: CmsEntryMeta; +} + +/** + * ListAllLockRecords Use Case - Lists all lock records without filtering + */ +export interface IListAllLockRecordsUseCase { + execute(input?: ListAllLockRecordsInput): Promise>; +} + +export interface IListAllLockRecordsUseCaseErrors { + persistence: LockRecordPersistenceError; +} + +type UseCaseError = IListAllLockRecordsUseCaseErrors[keyof IListAllLockRecordsUseCaseErrors]; + +export const ListAllLockRecordsUseCase = createAbstraction("ListAllLockRecordsUseCase"); + +export namespace ListAllLockRecordsUseCase { + export type Interface = IListAllLockRecordsUseCase; + export type Error = UseCaseError; +} + +/** + * ListAllLockRecordsRepository - Fetches all lock records from storage + */ +export interface IListAllLockRecordsRepository { + execute(input?: ListAllLockRecordsInput): Promise>; +} + +export interface IListAllLockRecordsRepositoryErrors { + persistence: LockRecordPersistenceError; +} + +type RepositoryError = IListAllLockRecordsRepositoryErrors[keyof IListAllLockRecordsRepositoryErrors]; + +export const ListAllLockRecordsRepository = createAbstraction("ListAllLockRecordsRepository"); + +export namespace ListAllLockRecordsRepository { + export type Interface = IListAllLockRecordsRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/feature.ts b/packages/api-record-locking/src/features/ListAllLockRecords/feature.ts new file mode 100644 index 00000000000..71255dd0dcd --- /dev/null +++ b/packages/api-record-locking/src/features/ListAllLockRecords/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListAllLockRecordsUseCase } from "./ListAllLockRecordsUseCase.js"; +import { ListAllLockRecordsRepository } from "./ListAllLockRecordsRepository.js"; + +export const ListAllLockRecordsFeature = createFeature({ + name: "ListAllLockRecords", + register(container) { + container.register(ListAllLockRecordsUseCase); + container.register(ListAllLockRecordsRepository).inSingletonScope(); + } +}); diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/index.ts b/packages/api-record-locking/src/features/ListAllLockRecords/index.ts new file mode 100644 index 00000000000..6448384b9a1 --- /dev/null +++ b/packages/api-record-locking/src/features/ListAllLockRecords/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions.js"; +export * from "./feature.js"; diff --git a/packages/api-record-locking/src/features/LockEntry/LockEntryEventsDecorator.ts b/packages/api-record-locking/src/features/LockEntry/LockEntryEventsDecorator.ts new file mode 100644 index 00000000000..b880ba5cafd --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/LockEntryEventsDecorator.ts @@ -0,0 +1,57 @@ +import { + type LockEntryInput, + LockEntryUseCase as UseCaseAbstraction, + LockEntryUseCase +} from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/index.js"; +import { Result } from "@webiny/feature/api/index.js"; +import { EntryAfterLockEvent, EntryBeforeLockEvent, EntryLockErrorEvent } from "./events.js"; + +class LockEntryErrorDecoratorImpl implements LockEntryUseCase.Interface { + constructor( + private readonly eventPublisher: EventPublisher.Interface, + private readonly decoratee: LockEntryUseCase.Interface + ) {} + async execute(input: LockEntryInput): Promise> { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeLockEvent({ + id: input.id, + type: input.type + }) + ); + + const result = await this.decoratee.execute(input); + + if (result.isFail()) { + const error = result.error; + + await this.eventPublisher.publish( + new EntryLockErrorEvent({ + id: input.id, + type: input.type, + error + }) + ); + + return result; + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterLockEvent({ + id: input.id, + type: input.type, + record: result.value + }) + ); + + return result; + } +} + +export const LockEntryEventsDecorator = LockEntryUseCase.createDecorator({ + decorator: LockEntryErrorDecoratorImpl, + dependencies: [EventPublisher] +}); diff --git a/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts b/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts index cd12cc50f84..abf030b9282 100644 --- a/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts +++ b/packages/api-record-locking/src/features/LockEntry/LockEntryUseCase.ts @@ -30,7 +30,6 @@ class LockEntryUseCaseImpl implements UseCaseAbstraction.Interface { const result = await this.repository.create(input); if (result.isFail()) { - // Wrap persistence errors in domain error return Result.fail(new LockEntryError(result.error)); } diff --git a/packages/api-record-locking/src/features/LockEntry/events.ts b/packages/api-record-locking/src/features/LockEntry/events.ts new file mode 100644 index 00000000000..b706c5fa9b1 --- /dev/null +++ b/packages/api-record-locking/src/features/LockEntry/events.ts @@ -0,0 +1,85 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; + +// ============================================================================ +// EntryBeforeLock Event +// ============================================================================ + +export interface EntryBeforeLockPayload { + id: string; + type: LockRecordEntryType; +} + +export class EntryBeforeLockEvent extends DomainEvent { + eventType = "RecordLocking/Entry/BeforeLock" as const; + + getHandlerAbstraction() { + return EntryBeforeLockHandler; + } +} + +export const EntryBeforeLockHandler = createAbstraction>( + "EntryBeforeLockHandler" +); + +export namespace EntryBeforeLockHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeLockEvent; +} + +// ============================================================================ +// EntryAfterLock Event +// ============================================================================ + +export interface EntryAfterLockPayload { + id: string; + type: LockRecordEntryType; + record: ILockRecord; +} + +export class EntryAfterLockEvent extends DomainEvent { + eventType = "RecordLocking/Entry/AfterLock" as const; + + getHandlerAbstraction() { + return EntryAfterLockHandler; + } +} + +export const EntryAfterLockHandler = createAbstraction>( + "EntryAfterLockHandler" +); + +export namespace EntryAfterLockHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterLockEvent; +} + +// ============================================================================ +// EntryLockError Event +// ============================================================================ + +export interface EntryLockErrorPayload { + id: string; + type: LockRecordEntryType; + error: Error; +} + +export class EntryLockErrorEvent extends DomainEvent { + eventType = "RecordLocking/Entry/LockError" as const; + + getHandlerAbstraction() { + return EntryLockErrorHandler; + } +} + +export const EntryLockErrorHandler = createAbstraction>( + "EntryLockErrorHandler" +); + +export namespace EntryLockErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryLockErrorEvent; +} diff --git a/packages/api-record-locking/src/features/LockEntry/feature.ts b/packages/api-record-locking/src/features/LockEntry/feature.ts index a97e7086f87..1bdff03502a 100644 --- a/packages/api-record-locking/src/features/LockEntry/feature.ts +++ b/packages/api-record-locking/src/features/LockEntry/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import { LockEntryUseCase } from "./LockEntryUseCase.js"; import { LockEntryRepository } from "./LockEntryRepository.js"; +import { LockEntryEventsDecorator } from "./LockEntryEventsDecorator.js"; export const LockEntryFeature = createFeature({ name: "LockEntry", register(container) { container.register(LockEntryUseCase); container.register(LockEntryRepository).inSingletonScope(); + container.registerDecorator(LockEntryEventsDecorator); } }); diff --git a/packages/api-record-locking/src/features/RecordLockingFeature.ts b/packages/api-record-locking/src/features/RecordLockingFeature.ts index 9df9f4799f5..6bfad7490d8 100644 --- a/packages/api-record-locking/src/features/RecordLockingFeature.ts +++ b/packages/api-record-locking/src/features/RecordLockingFeature.ts @@ -2,12 +2,15 @@ import { createFeature } from "@webiny/feature/api"; import type { CmsModel } from "@webiny/api-headless-cms/types"; import { RecordLockingConfig, RecordLockingModel } from "~/domain/abstractions.js"; import { GetLockRecordFeature } from "./GetLockRecord/feature.js"; +import { GetLockedEntryLockRecordFeature } from "./GetLockedEntryLockRecord/feature.js"; import { KickOutCurrentUserFeature } from "./KickOutCurrentUser/feature.js"; import { ListLockRecordsFeature } from "./ListLockRecords/feature.js"; +import { ListAllLockRecordsFeature } from "./ListAllLockRecords/feature.js"; import { IsEntryLockedFeature } from "./IsEntryLocked/feature.js"; import { LockEntryFeature } from "./LockEntry/feature.js"; import { UpdateEntryLockFeature } from "./UpdateEntryLock/feature.js"; import { UnlockEntryFeature } from "./UnlockEntry/feature.js"; +import { UnlockEntryRequestFeature } from "./UnlockEntryRequest/feature.js"; export interface RecordLockingParams { /** @@ -29,11 +32,14 @@ export const RecordLockingFeature = createFeature({ // Register all sub-features GetLockRecordFeature.register(container); + GetLockedEntryLockRecordFeature.register(container); KickOutCurrentUserFeature.register(container); ListLockRecordsFeature.register(container); + ListAllLockRecordsFeature.register(container); IsEntryLockedFeature.register(container); LockEntryFeature.register(container); UpdateEntryLockFeature.register(container); UnlockEntryFeature.register(container); + UnlockEntryRequestFeature.register(container); } }); diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryEventsDecorator.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryEventsDecorator.ts new file mode 100644 index 00000000000..c235b56e411 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryEventsDecorator.ts @@ -0,0 +1,59 @@ +import { + type UnlockEntryInput, + UnlockEntryUseCase as UseCaseAbstraction, + UnlockEntryUseCase +} from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/index.js"; +import { Result } from "@webiny/feature/api/index.js"; +import { EntryAfterUnlockEvent, EntryBeforeUnlockEvent, EntryUnlockErrorEvent } from "./events.js"; + +class UnlockEntryEventsDecoratorImpl implements UnlockEntryUseCase.Interface { + constructor( + private readonly eventPublisher: EventPublisher.Interface, + private readonly decoratee: UnlockEntryUseCase.Interface + ) {} + + async execute(input: UnlockEntryInput): Promise> { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeUnlockEvent({ + id: input.id, + type: input.type, + force: input.force + }) + ); + + const result = await this.decoratee.execute(input); + + if (result.isFail()) { + const error = result.error; + + await this.eventPublisher.publish( + new EntryUnlockErrorEvent({ + id: input.id, + type: input.type, + error + }) + ); + + return result; + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterUnlockEvent({ + id: input.id, + type: input.type, + record: result.value + }) + ); + + return result; + } +} + +export const UnlockEntryEventsDecorator = UnlockEntryUseCase.createDecorator({ + decorator: UnlockEntryEventsDecoratorImpl, + dependencies: [EventPublisher] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts index 36df089af56..2e20b493b7d 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts @@ -9,7 +9,7 @@ import { KickOutCurrentUserUseCase } from "../KickOutCurrentUser/abstractions.js import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import type { ILockRecord } from "~/domain/LockRecord.js"; import { LockRecordNotFoundError, NotSameIdentityError } from "~/domain/errors.js"; -import { hasFullAccessPermission } from "~/utils/hasFullAccessPermission.js"; +import { hasFullAccessPermission } from "./hasFullAccessPermission.js"; class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -38,7 +38,8 @@ class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { // If expired, cleanup and return error if (record.isExpired()) { await this.repository.delete(record.id); - return Result.fail(new LockRecordNotFoundError({ id: input.id })); + const error = new LockRecordNotFoundError({ id: input.id }); + return Result.fail(error); } // Check if user is the owner @@ -50,23 +51,21 @@ class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { // If not the owner, check if force unlock is allowed if (!isSameUser) { if (!input.force) { - return Result.fail( - new NotSameIdentityError({ - currentId: identity.id, - targetId: record.lockedBy.id - }) - ); + const error = new NotSameIdentityError({ + currentId: identity.id, + targetId: record.lockedBy.id + }); + return Result.fail(error); } // Check if user has permission to force unlock const hasAccess = await hasFullAccessPermission(this.identityContext); if (!hasAccess) { - return Result.fail( - new NotSameIdentityError({ - currentId: identity.id, - targetId: record.lockedBy.id - }) - ); + const error = new NotSameIdentityError({ + currentId: identity.id, + targetId: record.lockedBy.id + }); + return Result.fail(error); } shouldKickOut = true; diff --git a/packages/api-record-locking/src/features/UnlockEntry/events.ts b/packages/api-record-locking/src/features/UnlockEntry/events.ts new file mode 100644 index 00000000000..ac05915710b --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntry/events.ts @@ -0,0 +1,86 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; + +// ============================================================================ +// EntryBeforeUnlock Event +// ============================================================================ + +export interface EntryBeforeUnlockPayload { + id: string; + type: LockRecordEntryType; + force?: boolean; +} + +export class EntryBeforeUnlockEvent extends DomainEvent { + eventType = "RecordLocking/Entry/BeforeUnlock" as const; + + getHandlerAbstraction() { + return EntryBeforeUnlockHandler; + } +} + +export const EntryBeforeUnlockHandler = createAbstraction>( + "EntryBeforeUnlockHandler" +); + +export namespace EntryBeforeUnlockHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeUnlockEvent; +} + +// ============================================================================ +// EntryAfterUnlock Event +// ============================================================================ + +export interface EntryAfterUnlockPayload { + id: string; + type: LockRecordEntryType; + record: ILockRecord; +} + +export class EntryAfterUnlockEvent extends DomainEvent { + eventType = "RecordLocking/Entry/AfterUnlock" as const; + + getHandlerAbstraction() { + return EntryAfterUnlockHandler; + } +} + +export const EntryAfterUnlockHandler = createAbstraction>( + "EntryAfterUnlockHandler" +); + +export namespace EntryAfterUnlockHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterUnlockEvent; +} + +// ============================================================================ +// EntryUnlockError Event +// ============================================================================ + +export interface EntryUnlockErrorPayload { + id: string; + type: LockRecordEntryType; + error: Error; +} + +export class EntryUnlockErrorEvent extends DomainEvent { + eventType = "RecordLocking/Entry/UnlockError" as const; + + getHandlerAbstraction() { + return EntryUnlockErrorHandler; + } +} + +export const EntryUnlockErrorHandler = createAbstraction>( + "EntryUnlockErrorHandler" +); + +export namespace EntryUnlockErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryUnlockErrorEvent; +} diff --git a/packages/api-record-locking/src/features/UnlockEntry/feature.ts b/packages/api-record-locking/src/features/UnlockEntry/feature.ts index 259415425fb..186ca207cb0 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/feature.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import { UnlockEntryUseCase } from "./UnlockEntryUseCase.js"; import { UnlockEntryRepository } from "./UnlockEntryRepository.js"; +import { UnlockEntryEventsDecorator } from "./UnlockEntryEventsDecorator.js"; export const UnlockEntryFeature = createFeature({ name: "UnlockEntry", register(container) { container.register(UnlockEntryUseCase); container.register(UnlockEntryRepository).inSingletonScope(); + container.registerDecorator(UnlockEntryEventsDecorator); } }); diff --git a/packages/api-record-locking/src/utils/hasFullAccessPermission.ts b/packages/api-record-locking/src/features/UnlockEntry/hasFullAccessPermission.ts similarity index 100% rename from packages/api-record-locking/src/utils/hasFullAccessPermission.ts rename to packages/api-record-locking/src/features/UnlockEntry/hasFullAccessPermission.ts diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestEventsDecorator.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestEventsDecorator.ts new file mode 100644 index 00000000000..0492ed8ff12 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestEventsDecorator.ts @@ -0,0 +1,64 @@ +import { + type UnlockEntryRequestInput, + UnlockEntryRequestUseCase as UseCaseAbstraction, + UnlockEntryRequestUseCase +} from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/index.js"; +import { Result } from "@webiny/feature/api/index.js"; +import { + EntryAfterUnlockRequestEvent, + EntryBeforeUnlockRequestEvent, + EntryUnlockRequestErrorEvent +} from "./events.js"; + +class UnlockEntryRequestEventsDecoratorImpl implements UnlockEntryRequestUseCase.Interface { + constructor( + private readonly eventPublisher: EventPublisher.Interface, + private readonly decoratee: UnlockEntryRequestUseCase.Interface + ) {} + + async execute( + input: UnlockEntryRequestInput + ): Promise> { + // Publish before event + await this.eventPublisher.publish( + new EntryBeforeUnlockRequestEvent({ + id: input.id, + type: input.type + }) + ); + + const result = await this.decoratee.execute(input); + + if (result.isFail()) { + const error = result.error; + + await this.eventPublisher.publish( + new EntryUnlockRequestErrorEvent({ + id: input.id, + type: input.type, + error + }) + ); + + return result; + } + + // Publish after event + await this.eventPublisher.publish( + new EntryAfterUnlockRequestEvent({ + id: input.id, + type: input.type, + record: result.value + }) + ); + + return result; + } +} + +export const UnlockEntryRequestEventsDecorator = UnlockEntryRequestUseCase.createDecorator({ + decorator: UnlockEntryRequestEventsDecoratorImpl, + dependencies: [EventPublisher] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestRepository.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestRepository.ts new file mode 100644 index 00000000000..a4175264118 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestRepository.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { UnlockEntryRequestRepository as RepositoryAbstraction } from "./abstractions.js"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { RecordLockingModel } from "~/domain/abstractions.js"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; +import { UnlockEntryRequestError } from "~/domain/errors.js"; +import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; +import { createIdentifier } from "@webiny/utils"; + +class UnlockEntryRequestRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private identityContext: IdentityContext.Interface, + private model: CmsModel + ) {} + + async update(record: ILockRecord): Promise> { + try { + const entryId = createLockRecordDatabaseId(record.id); + const id = createIdentifier({ + id: entryId, + version: 1 + }); + + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.updateEntry.execute(this.model, id, record.toObject()); + }); + + if (result.isFail()) { + return Result.fail(new UnlockEntryRequestError(result.error)); + } + + return Result.ok(record); + } catch (error) { + return Result.fail(new UnlockEntryRequestError(error as Error)); + } + } +} + +export const UnlockEntryRequestRepository = RepositoryAbstraction.createImplementation({ + implementation: UnlockEntryRequestRepositoryImpl, + dependencies: [UpdateEntryUseCase, IdentityContext, RecordLockingModel] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestUseCase.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestUseCase.ts new file mode 100644 index 00000000000..80f7736a4e0 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/UnlockEntryRequestUseCase.ts @@ -0,0 +1,102 @@ +import { Result } from "@webiny/feature/api"; +import { + UnlockEntryRequestUseCase as UseCaseAbstraction, + UnlockEntryRequestRepository, + UnlockEntryRequestInput +} from "./abstractions.js"; +import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import { + EntryNotLockedError, + UnlockRequestAlreadySentError, + LockRecordNotFoundError, + LockRecordPersistenceError +} from "~/domain/errors.js"; +import { RecordLockingLockRecordActionType } from "~/domain/types.js"; + +class UnlockEntryRequestUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getLockRecord: GetLockRecordUseCase.Interface, + private repository: UnlockEntryRequestRepository.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute( + input: UnlockEntryRequestInput + ): Promise> { + // Get the lock record + const recordResult = await this.getLockRecord.execute(input); + + // If not found, it means entry is not locked + if (recordResult.isFail()) { + if (recordResult.error instanceof LockRecordNotFoundError) { + const error = new EntryNotLockedError({ id: input.id, type: input.type }); + return Result.fail(error); + } + // Other errors + return Result.fail(new LockRecordPersistenceError(recordResult.error)); + } + + const record = recordResult.value; + + // If expired, entry is not locked + if (record.isExpired()) { + const error = new EntryNotLockedError({ id: input.id, type: input.type }); + return Result.fail(error); + } + + // Check if unlock request already exists + const unlockRequested = record.getUnlockRequested(); + if (unlockRequested) { + const currentIdentity = this.identityContext.getIdentity(); + + // If a different user already requested unlock, deny + if (unlockRequested.createdBy.id !== currentIdentity.id) { + const error = new UnlockRequestAlreadySentError({ + id: input.id, + type: input.type, + identityId: unlockRequested.createdBy.id + }); + return Result.fail(error); + } + + // If same user but already approved or denied, return the record + const approved = record.getUnlockApproved(); + const denied = record.getUnlockDenied(); + if (approved || denied) { + return Result.ok(record); + } + + // If same user and pending, treat as duplicate + const error = new UnlockRequestAlreadySentError({ + id: input.id, + type: input.type, + identityId: unlockRequested.createdBy.id + }); + return Result.fail(error); + } + + // Add unlock request action + const identity = this.identityContext.getIdentity(); + record.addAction({ + type: RecordLockingLockRecordActionType.requested, + createdOn: new Date(), + createdBy: identity + }); + + // Update the record + const updateResult = await this.repository.update(record); + + if (updateResult.isFail()) { + return Result.fail(updateResult.error); + } + + return Result.ok(record); + } +} + +export const UnlockEntryRequestUseCase = UseCaseAbstraction.createImplementation({ + implementation: UnlockEntryRequestUseCaseImpl, + dependencies: [GetLockRecordUseCase, UnlockEntryRequestRepository, IdentityContext] +}); diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts new file mode 100644 index 00000000000..b17d475b146 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts @@ -0,0 +1,59 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; +import type { + EntryNotLockedError, + UnlockRequestAlreadySentError, + UnlockEntryRequestError, + LockRecordPersistenceError +} from "~/domain/errors.js"; + +// Input type +export interface UnlockEntryRequestInput { + id: string; + type: LockRecordEntryType; +} + +/** + * UnlockEntryRequest Use Case - Adds unlock request to lock record + */ +export interface IUnlockEntryRequestUseCase { + execute(input: UnlockEntryRequestInput): Promise>; +} + +export interface IUnlockEntryRequestUseCaseErrors { + notLocked: EntryNotLockedError; + alreadySent: UnlockRequestAlreadySentError; + persistence: LockRecordPersistenceError; + requestError: UnlockEntryRequestError; +} + +type UseCaseError = IUnlockEntryRequestUseCaseErrors[keyof IUnlockEntryRequestUseCaseErrors]; + +export const UnlockEntryRequestUseCase = createAbstraction("UnlockEntryRequestUseCase"); + +export namespace UnlockEntryRequestUseCase { + export type Interface = IUnlockEntryRequestUseCase; + export type Error = UseCaseError; +} + +/** + * UnlockEntryRequestRepository - Updates lock record with unlock request + */ +export interface IUnlockEntryRequestRepository { + update(record: ILockRecord): Promise>; +} + +export interface IUnlockEntryRequestRepositoryErrors { + persistence: UnlockEntryRequestError; +} + +type RepositoryError = IUnlockEntryRequestRepositoryErrors[keyof IUnlockEntryRequestRepositoryErrors]; + +export const UnlockEntryRequestRepository = createAbstraction("UnlockEntryRequestRepository"); + +export namespace UnlockEntryRequestRepository { + export type Interface = IUnlockEntryRequestRepository; + export type Error = RepositoryError; +} diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts new file mode 100644 index 00000000000..096a629e669 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts @@ -0,0 +1,85 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { ILockRecord } from "~/domain/LockRecord.js"; +import type { LockRecordEntryType } from "~/domain/types.js"; + +// ============================================================================ +// EntryBeforeUnlockRequest Event +// ============================================================================ + +export interface EntryBeforeUnlockRequestPayload { + id: string; + type: LockRecordEntryType; +} + +export class EntryBeforeUnlockRequestEvent extends DomainEvent { + eventType = "RecordLocking/Entry/BeforeUnlockRequest" as const; + + getHandlerAbstraction() { + return EntryBeforeUnlockRequestHandler; + } +} + +export const EntryBeforeUnlockRequestHandler = createAbstraction>( + "EntryBeforeUnlockRequestHandler" +); + +export namespace EntryBeforeUnlockRequestHandler { + export type Interface = IEventHandler; + export type Event = EntryBeforeUnlockRequestEvent; +} + +// ============================================================================ +// EntryAfterUnlockRequest Event +// ============================================================================ + +export interface EntryAfterUnlockRequestPayload { + id: string; + type: LockRecordEntryType; + record: ILockRecord; +} + +export class EntryAfterUnlockRequestEvent extends DomainEvent { + eventType = "RecordLocking/Entry/AfterUnlockRequest" as const; + + getHandlerAbstraction() { + return EntryAfterUnlockRequestHandler; + } +} + +export const EntryAfterUnlockRequestHandler = createAbstraction>( + "EntryAfterUnlockRequestHandler" +); + +export namespace EntryAfterUnlockRequestHandler { + export type Interface = IEventHandler; + export type Event = EntryAfterUnlockRequestEvent; +} + +// ============================================================================ +// EntryUnlockRequestError Event +// ============================================================================ + +export interface EntryUnlockRequestErrorPayload { + id: string; + type: LockRecordEntryType; + error: Error; +} + +export class EntryUnlockRequestErrorEvent extends DomainEvent { + eventType = "RecordLocking/Entry/UnlockRequestError" as const; + + getHandlerAbstraction() { + return EntryUnlockRequestErrorHandler; + } +} + +export const EntryUnlockRequestErrorHandler = createAbstraction>( + "EntryUnlockRequestErrorHandler" +); + +export namespace EntryUnlockRequestErrorHandler { + export type Interface = IEventHandler; + export type Event = EntryUnlockRequestErrorEvent; +} diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/feature.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/feature.ts new file mode 100644 index 00000000000..956dcc1594a --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/feature.ts @@ -0,0 +1,13 @@ +import { createFeature } from "@webiny/feature/api"; +import { UnlockEntryRequestUseCase } from "./UnlockEntryRequestUseCase.js"; +import { UnlockEntryRequestRepository } from "./UnlockEntryRequestRepository.js"; +import { UnlockEntryRequestEventsDecorator } from "./UnlockEntryRequestEventsDecorator.js"; + +export const UnlockEntryRequestFeature = createFeature({ + name: "UnlockEntryRequest", + register(container) { + container.register(UnlockEntryRequestUseCase); + container.register(UnlockEntryRequestRepository).inSingletonScope(); + container.registerDecorator(UnlockEntryRequestEventsDecorator); + } +}); diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/index.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/index.ts new file mode 100644 index 00000000000..7482d15f471 --- /dev/null +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/index.ts @@ -0,0 +1,3 @@ +export * from "./abstractions.js"; +export * from "./events.js"; +export * from "./feature.js"; diff --git a/packages/api-record-locking/src/graphql/checkPermissions.ts b/packages/api-record-locking/src/graphql/checkPermissions.ts new file mode 100644 index 00000000000..10caf8c77c3 --- /dev/null +++ b/packages/api-record-locking/src/graphql/checkPermissions.ts @@ -0,0 +1,15 @@ +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { NotAuthorizedError } from "@webiny/api-core/features/security/shared"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; + +/** + * Simple permission check. Only authenticated users can access the websockets API via GraphQL + */ +export const checkPermissions = async (context: ApiCoreContext): Promise => { + const identityContext = context.container.resolve(IdentityContext); + const identity = identityContext.getIdentity(); + + if (identity.isAnonymous()) { + throw new NotAuthorizedError(); + } +}; diff --git a/packages/api-record-locking/src/utils/resolve.ts b/packages/api-record-locking/src/graphql/resolve.ts similarity index 100% rename from packages/api-record-locking/src/utils/resolve.ts rename to packages/api-record-locking/src/graphql/resolve.ts diff --git a/packages/api-record-locking/src/graphql/schema.ts b/packages/api-record-locking/src/graphql/schema.ts index d430509f8f3..8e1642035e6 100644 --- a/packages/api-record-locking/src/graphql/schema.ts +++ b/packages/api-record-locking/src/graphql/schema.ts @@ -1,35 +1,42 @@ -import { resolve, resolveList } from "~/utils/resolve.js"; -import type { Context } from "~/types.js"; +import { resolve, resolveList } from "./resolve.js"; import type { IGraphQLSchemaPlugin } from "@webiny/handler-graphql"; -import { createGraphQLSchemaPlugin, NotFoundError } from "@webiny/handler-graphql"; +import { createGraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { renderFields } from "@webiny/api-headless-cms/utils/renderFields.js"; -import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; import { renderListFilterFields } from "@webiny/api-headless-cms/utils/renderListFilterFields.js"; import { renderSortEnum } from "@webiny/api-headless-cms/utils/renderSortEnum.js"; -import { checkPermissions } from "~/utils/checkPermissions.js"; +import { checkPermissions } from "./checkPermissions.js"; +import { IsEntryLockedUseCase } from "~/features/IsEntryLocked/abstractions.js"; +import { GetLockRecordUseCase } from "~/features/GetLockRecord/abstractions.js"; +import { GetLockedEntryLockRecordUseCase } from "~/features/GetLockedEntryLockRecord/abstractions.js"; +import { ListLockRecordsUseCase } from "~/features/ListLockRecords/abstractions.js"; +import { ListAllLockRecordsUseCase } from "~/features/ListAllLockRecords/abstractions.js"; +import { LockEntryUseCase } from "~/features/LockEntry/abstractions.js"; +import { UpdateEntryLockUseCase } from "~/features/UpdateEntryLock/abstractions.js"; +import { UnlockEntryUseCase } from "~/features/UnlockEntry/abstractions.js"; +import { UnlockEntryRequestUseCase } from "~/features/UnlockEntryRequest/abstractions.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { CmsModel } from "@webiny/api-headless-cms/types/model.js"; +import type { CmsFieldTypePlugins } from "@webiny/api-headless-cms/types/index.js"; interface Params { - context: Pick; + // Record locking model + model: CmsModel; + // All public models + models: CmsModel[]; + fieldTypePlugins: CmsFieldTypePlugins; } export const createGraphQLSchema = async ( params: Params -): Promise> => { - const context = params.context; +): Promise> => { + // Record locking model + const model = params.model; - const model = await context.recordLocking.getModel(); - - const models = await context.security.withoutAuthorization(async () => { - return (await context.cms.listModels()).filter(model => { - if (model.fields.length === 0) { - return false; - } else if (model.isPrivate) { - return false; - } - return true; - }); + // Other public models that have at least one field + const models = params.models.filter(model => { + return model.fields.length > 0; }); - const fieldTypePlugins = createFieldTypePluginRecords(context.plugins); + const fieldTypePlugins = params.fieldTypePlugins; const recordLockingFields = renderFields({ models, @@ -54,7 +61,7 @@ export const createGraphQLSchema = async ( sorterPlugins: [] }); - const plugin = createGraphQLSchemaPlugin({ + const plugin = createGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` ${recordLockingFields.map(f => f.typeDefs).join("\n")} @@ -199,45 +206,67 @@ export const createGraphQLSchema = async ( async isEntryLocked(_, args, context) { return resolve(async () => { await checkPermissions(context); - return context.recordLocking.isEntryLocked({ + const useCase = context.container.resolve(IsEntryLockedUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, async getLockRecord(_, args, context) { return resolve(async () => { await checkPermissions(context); - const result = await context.recordLocking.getLockRecord({ + const useCase = context.container.resolve(GetLockRecordUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); - if (result) { - return result; + if (result.isFail()) { + throw result.error; } - throw new NotFoundError("Lock record not found."); + return result.value; }); }, async getLockedEntryLockRecord(_, args, context) { return resolve(async () => { await checkPermissions(context); - return await context.recordLocking.getLockedEntryLockRecord({ + const useCase = context.container.resolve(GetLockedEntryLockRecordUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); + // Returns null if not found/expired/locked by current user + if (result.isFail()) { + return null; + } + return result.value; }); }, async listLockRecords(_, args, context) { return resolveList(async () => { await checkPermissions(context); - return await context.recordLocking.listLockRecords(args); + const useCase = context.container.resolve(ListLockRecordsUseCase); + const result = await useCase.execute(args); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, listAllLockRecords(_, args, context) { return resolveList(async () => { await checkPermissions(context); - return await context.recordLocking.listAllLockRecords(args); + const useCase = context.container.resolve(ListAllLockRecordsUseCase); + const result = await useCase.execute(args); + if (result.isFail()) { + throw result.error; + } + return result.value; }); } }, @@ -245,38 +274,58 @@ export const createGraphQLSchema = async ( async lockEntry(_, args, context) { return resolve(async () => { await checkPermissions(context); - return context.recordLocking.lockEntry({ + const useCase = context.container.resolve(LockEntryUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, async updateEntryLock(_, args, context) { return resolve(async () => { await checkPermissions(context); - return context.recordLocking.updateEntryLock({ + const useCase = context.container.resolve(UpdateEntryLockUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, async unlockEntry(_, args, context) { return resolve(async () => { await checkPermissions(context); - return await context.recordLocking.unlockEntry({ + const useCase = context.container.resolve(UnlockEntryUseCase); + const result = await useCase.execute({ id: args.id, type: args.type, force: args.force }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, async unlockEntryRequest(_, args, context) { return resolve(async () => { await checkPermissions(context); - return await context.recordLocking.unlockEntryRequest({ + const useCase = context.container.resolve(UnlockEntryRequestUseCase); + const result = await useCase.execute({ id: args.id, type: args.type }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); } } diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index 5c632457a4c..ceeea2c1c5a 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -1,14 +1,13 @@ -import { createGraphQLSchema } from "~/graphql/schema.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; import { ContextPlugin } from "@webiny/api"; -import type { Context } from "~/types.js"; -import { createRecordLockingCrud } from "~/crud/crud.js"; -import { createLockingModel } from "~/crud/model.js"; import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; -import { RecordLockingConfig, RecordLockingModel } from "~/domain/index.js"; +import { ListModelsUseCase } from "@webiny/api-headless-cms/features/contentModel/ListModels"; import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; -import { RECORD_LOCKING_MODEL_ID } from "~/domain/model.js"; +import { createLockingModel, RECORD_LOCKING_MODEL_ID } from "~/domain/model.js"; import { getTimeout } from "~/utils/getTimeout.js"; import { RecordLockingFeature } from "~/features/RecordLockingFeature.js"; +import { createGraphQLSchema } from "~/graphql/schema.js"; +import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; export interface ICreateContextPluginParams { /** @@ -18,29 +17,34 @@ export interface ICreateContextPluginParams { } const createContextPlugin = (params?: ICreateContextPluginParams) => { - const plugin = new ContextPlugin(async context => { + const plugin = new ContextPlugin(async context => { const wcp = context.container.resolve(WcpContext); const getModel = context.container.resolve(GetModelUseCase); + const listModels = context.container.resolve(ListModelsUseCase); if (!wcp.canUseRecordLocking()) { return; } + // Register model plugin context.plugins.register(createLockingModel()); + // Determine timeout value const timeout = getTimeout(params?.timeout); - context.recordLocking = await createRecordLockingCrud({ - context, - timeout - }); + // Fetch CMS model to use for storing record locking data + const recordLockingModel = await getModel.execute(RECORD_LOCKING_MODEL_ID); + const publicModels = await listModels.execute({ includePrivate: false }); - const graphQlPlugin = await createGraphQLSchema({ context }); + // Register GraphQL schema plugin + const graphQlPlugin = await createGraphQLSchema({ + model: recordLockingModel.value, + models: publicModels.value, + fieldTypePlugins: createFieldTypePluginRecords(context.plugins) + }); context.plugins.register(graphQlPlugin); - const recordLockingModel = await getModel.execute(RECORD_LOCKING_MODEL_ID); - // Register features RecordLockingFeature.register(context.container, { timeout, diff --git a/packages/api-record-locking/src/types.ts b/packages/api-record-locking/src/types.ts index bb0d1a8f2fa..44401f139eb 100644 --- a/packages/api-record-locking/src/types.ts +++ b/packages/api-record-locking/src/types.ts @@ -1,18 +1,10 @@ import type { - CmsContext, CmsEntryListParams, CmsEntryMeta, CmsIdentity, - CmsModel, CmsModelManager } from "@webiny/api-headless-cms/types/index.js"; import { CmsEntry, CmsError } from "@webiny/api-headless-cms/types/index.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import type { - Context as IWebsocketsContext, - IWebsocketsContextObject -} from "@webiny/api-websockets/types.js"; -import type { SecurityPermission } from "@webiny/api-core/types/security.js"; export type { CmsError, CmsEntry }; @@ -22,18 +14,6 @@ export type IRecordLockingModelManager = CmsModelManager; -} - -export interface IGetWebsocketsContextCallable { - (): IWebsocketsContextObject; -} - -export interface IGetIdentity { - (): IRecordLockingIdentity; -} - export interface IRecordLockingLockRecordValues { targetId: string; type: IRecordLockingLockRecordEntryType; @@ -103,144 +83,3 @@ export type IRecordLockingListAllLockRecordsParams = Pick< export type IRecordLockingListLockRecordsParams = IRecordLockingListAllLockRecordsParams; -export interface IRecordLockingListAllLockRecordsResponse { - items: IRecordLockingLockRecord[]; - meta: IRecordLockingMeta; -} - -export type IRecordLockingListLockRecordsResponse = IRecordLockingListAllLockRecordsResponse; - -export interface IRecordLockingGetLockRecordParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IRecordLockingIsLockedParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IRecordLockingGetLockedEntryLockRecordParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IRecordLockingLockEntryParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IRecordLockingUpdateEntryLockParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface IRecordLockingUnlockEntryParams { - id: string; - type: IRecordLockingLockRecordEntryType; - force?: boolean; -} - -export interface IRecordLockingUnlockEntryRequestParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface OnEntryBeforeLockTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface OnEntryAfterLockTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - record: IRecordLockingLockRecord; -} - -export interface OnEntryLockErrorTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - error: CmsError; -} - -export interface OnEntryBeforeUnlockTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - getIdentity: IGetIdentity; -} - -export interface OnEntryAfterUnlockTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - record: IRecordLockingLockRecord; -} - -export interface OnEntryUnlockErrorTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - error: CmsError; -} - -export interface OnEntryBeforeUnlockRequestTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; -} - -export interface OnEntryAfterUnlockRequestTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - record: IRecordLockingLockRecord; -} - -export interface OnEntryUnlockRequestErrorTopicParams { - id: string; - type: IRecordLockingLockRecordEntryType; - error: CmsError; -} - -export interface IRecordLocking { - /** - * In milliseconds. - */ - getTimeout: () => number; - onEntryBeforeLock: Topic; - onEntryAfterLock: Topic; - onEntryLockError: Topic; - onEntryBeforeUnlock: Topic; - onEntryAfterUnlock: Topic; - onEntryUnlockError: Topic; - onEntryBeforeUnlockRequest: Topic; - onEntryAfterUnlockRequest: Topic; - onEntryUnlockRequestError: Topic; - getModel(): Promise; - listAllLockRecords( - params?: IRecordLockingListAllLockRecordsParams - ): Promise; - /** - * Same call as listAllLockRecords, except this one will filter out records with expired lock. - */ - listLockRecords( - params?: IRecordLockingListLockRecordsParams - ): Promise; - getLockRecord( - params: IRecordLockingGetLockRecordParams - ): Promise; - isEntryLocked(params: IRecordLockingIsLockedParams): Promise; - getLockedEntryLockRecord( - params: IRecordLockingGetLockedEntryLockRecordParams - ): Promise; - lockEntry(params: IRecordLockingLockEntryParams): Promise; - updateEntryLock(params: IRecordLockingUpdateEntryLockParams): Promise; - unlockEntry(params: IRecordLockingUnlockEntryParams): Promise; - unlockEntryRequest( - params: IRecordLockingUnlockEntryRequestParams - ): Promise; -} - -export interface Context extends CmsContext, IWebsocketsContext { - recordLocking: IRecordLocking; -} - -export interface RecordLockingSecurityPermission extends SecurityPermission { - canForceUnlock?: string; -} diff --git a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts deleted file mode 100644 index c7e266ba8ea..00000000000 --- a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - IGetLockRecordUseCase, - IGetLockRecordUseCaseExecuteParams -} from "~/abstractions/IGetLockRecordUseCase.js"; -import type { IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; -import { createIdentifier } from "@webiny/utils"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; -import type { Security } from "@webiny/api-core/types/security.js"; - -export interface IGetLockRecordUseCaseParams { - getManager(): Promise; - getSecurity(): Pick; - convert: ConvertEntryToLockRecordCb; -} - -export class GetLockRecordUseCase implements IGetLockRecordUseCase { - private readonly getManager: IGetLockRecordUseCaseParams["getManager"]; - private readonly getSecurity: IGetLockRecordUseCaseParams["getSecurity"]; - private readonly convert: ConvertEntryToLockRecordCb; - - public constructor(params: IGetLockRecordUseCaseParams) { - this.getManager = params.getManager; - this.getSecurity = params.getSecurity; - this.convert = params.convert; - } - - public async execute( - input: IGetLockRecordUseCaseExecuteParams - ): Promise { - const recordId = createLockRecordDatabaseId(input.id); - const id = createIdentifier({ - id: recordId, - version: 1 - }); - const security = this.getSecurity(); - try { - const manager = await this.getManager(); - return await security.withoutAuthorization(async () => { - const result = await manager.get(id); - return this.convert(result); - }); - } catch (ex) { - if (ex instanceof NotFoundError) { - return null; - } - throw ex; - } - } -} diff --git a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts deleted file mode 100644 index e7c981b2980..00000000000 --- a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import type { IGetIdentity, IRecordLockingLockRecord } from "~/types.js"; -import type { - IGetLockedEntryLockRecordUseCase, - IGetLockedEntryLockRecordUseCaseExecuteParams -} from "~/abstractions/IGetLockedEntryLockRecordUseCase.js"; - -export interface IGetLockedEntryLockRecordUseCaseParams { - getLockRecordUseCase: IGetLockRecordUseCase; - getIdentity: IGetIdentity; -} - -/** - * This use case is used to get a lock record for an entry - and the entry is still locked by someone other than the current user. - */ -export class GetLockedEntryLockRecordUseCase implements IGetLockedEntryLockRecordUseCase { - private readonly getLockRecordUseCase: IGetLockRecordUseCase; - private readonly getIdentity: IGetIdentity; - - public constructor(params: IGetLockedEntryLockRecordUseCaseParams) { - this.getLockRecordUseCase = params.getLockRecordUseCase; - this.getIdentity = params.getIdentity; - } - - public async execute( - params: IGetLockedEntryLockRecordUseCaseExecuteParams - ): Promise { - const result = await this.getLockRecordUseCase.execute(params); - if (!result?.lockedBy?.id || result.isExpired()) { - return null; - } - const identity = this.getIdentity(); - if (identity.id === result.lockedBy.id) { - return null; - } - return result; - } -} diff --git a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts deleted file mode 100644 index 1b72c73f461..00000000000 --- a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { - IIsEntryLockedUseCase, - IIsEntryLockedUseCaseExecuteParams -} from "~/abstractions/IIsEntryLocked.js"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import type { IGetIdentity } from "~/types.js"; - -export interface IIsEntryLockedParams { - getLockRecordUseCase: IGetLockRecordUseCase; - getIdentity: IGetIdentity; -} - -export class IsEntryLockedUseCase implements IIsEntryLockedUseCase { - private readonly getLockRecordUseCase: IGetLockRecordUseCase; - private readonly getIdentity: IGetIdentity; - - public constructor(params: IIsEntryLockedParams) { - this.getLockRecordUseCase = params.getLockRecordUseCase; - this.getIdentity = params.getIdentity; - } - - public async execute(params: IIsEntryLockedUseCaseExecuteParams): Promise { - try { - const result = await this.getLockRecordUseCase.execute(params); - if (!result || result.isExpired()) { - return false; - } - const identity = this.getIdentity(); - - return result.lockedBy.id !== identity.id; - } catch (ex) { - if (ex instanceof NotFoundError === false) { - throw ex; - } - return false; - } - } -} diff --git a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts deleted file mode 100644 index 35e9073b8b7..00000000000 --- a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { - IKickOutCurrentUserUseCase, - IKickOutCurrentUserUseCaseExecuteParams -} from "~/abstractions/IKickOutCurrentUserUseCase.js"; -import type { IGetIdentity, IGetWebsocketsContextCallable } from "~/types.js"; -import { parseIdentifier } from "@webiny/utils"; - -export interface IKickOutCurrentUserUseCaseParams { - getWebsockets: IGetWebsocketsContextCallable; - getIdentity: IGetIdentity; -} - -export class KickOutCurrentUserUseCase implements IKickOutCurrentUserUseCase { - private readonly getWebsockets: IGetWebsocketsContextCallable; - private readonly getIdentity: IGetIdentity; - - public constructor(params: IKickOutCurrentUserUseCaseParams) { - this.getWebsockets = params.getWebsockets; - this.getIdentity = params.getIdentity; - } - - public async execute(record: IKickOutCurrentUserUseCaseExecuteParams): Promise { - const { lockedBy, id } = record; - - const websockets = this.getWebsockets(); - - const { id: entryId } = parseIdentifier(id); - - const identity = this.getIdentity(); - - /** - * We do not want any errors to leak out of this method. - * Just log the error, if any. - */ - try { - await websockets.send( - { id: lockedBy.id }, - { - action: `recordLocking.entry.kickOut.${entryId}`, - data: { - record: record.toObject(), - user: identity - } - } - ); - } catch (ex) { - console.error( - `Could not send the kickOut message to a user with identity id: ${lockedBy.id}. More info in next log line.` - ); - console.info(ex); - } - } -} diff --git a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts deleted file mode 100644 index 89f9bc94049..00000000000 --- a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { - IListAllLockRecordsUseCase, - IListAllLockRecordsUseCaseExecuteParams, - IListAllLockRecordsUseCaseExecuteResponse -} from "~/abstractions/IListAllLockRecordsUseCase.js"; -import type { IRecordLockingModelManager } from "~/types.js"; -import { convertWhereCondition } from "~/utils/convertWhereCondition.js"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; - -export interface IListAllLockRecordsUseCaseParams { - getManager(): Promise; - convert: ConvertEntryToLockRecordCb; -} - -export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase { - private readonly getManager: () => Promise; - private readonly convert: ConvertEntryToLockRecordCb; - - public constructor(params: IListAllLockRecordsUseCaseParams) { - this.getManager = params.getManager; - this.convert = params.convert; - } - public async execute( - input: IListAllLockRecordsUseCaseExecuteParams - ): Promise { - try { - const manager = await this.getManager(); - const params: IListAllLockRecordsUseCaseExecuteParams = { - ...input, - where: convertWhereCondition(input.where) - }; - - const [items, meta] = await manager.listLatest(params); - return { - items: items.map(item => { - return this.convert(item); - }), - meta - }; - } catch (ex) { - throw ex; - } - } -} diff --git a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts deleted file mode 100644 index d027c0427b8..00000000000 --- a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { - IListLockRecordsUseCase, - IListLockRecordsUseCaseExecuteParams, - IListLockRecordsUseCaseExecuteResponse -} from "~/abstractions/IListLockRecordsUseCase.js"; -import type { IGetIdentity } from "~/types.js"; - -export interface IListLockRecordsUseCaseParams { - listAllLockRecordsUseCase: IListLockRecordsUseCase; - timeout: number; - getIdentity: IGetIdentity; -} - -export class ListLockRecordsUseCase implements IListLockRecordsUseCase { - private readonly listAllLockRecordsUseCase: IListLockRecordsUseCase; - private readonly timeout: number; - private readonly getIdentity: IGetIdentity; - - public constructor(params: IListLockRecordsUseCaseParams) { - this.listAllLockRecordsUseCase = params.listAllLockRecordsUseCase; - this.timeout = params.timeout; - this.getIdentity = params.getIdentity; - } - public async execute( - input: IListLockRecordsUseCaseExecuteParams - ): Promise { - const identity = this.getIdentity(); - return this.listAllLockRecordsUseCase.execute({ - ...input, - where: { - ...input.where, - createdBy_not: identity.id, - savedOn_gte: new Date(new Date().getTime() - this.timeout) - } - }); - } -} diff --git a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts b/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts deleted file mode 100644 index b463f1eaae4..00000000000 --- a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts +++ /dev/null @@ -1,91 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { - ILockEntryUseCase, - ILockEntryUseCaseExecuteParams -} from "~/abstractions/ILockEntryUseCase.js"; -import type { - IRecordLockingIdentity, - IRecordLockingLockRecord, - IRecordLockingLockRecordValues, - IRecordLockingModelManager -} from "~/types.js"; -import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked.js"; -import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; -import { Security } from "@webiny/api-core/types/security.js"; -import type { CmsIdentity } from "@webiny/api-headless-cms/types/index.js"; - -export interface ILockEntryUseCaseParams { - isEntryLockedUseCase: IIsEntryLockedUseCase; - getManager(): Promise; - getSecurity(): Pick; - getIdentity(): IRecordLockingIdentity; - convert: ConvertEntryToLockRecordCb; -} - -export class LockEntryUseCase implements ILockEntryUseCase { - private readonly isEntryLockedUseCase: IIsEntryLockedUseCase; - private readonly getManager: ILockEntryUseCaseParams["getManager"]; - private readonly getSecurity: ILockEntryUseCaseParams["getSecurity"]; - private readonly getIdentity: ILockEntryUseCaseParams["getIdentity"]; - private readonly convert: ConvertEntryToLockRecordCb; - - public constructor(params: ILockEntryUseCaseParams) { - this.isEntryLockedUseCase = params.isEntryLockedUseCase; - this.getManager = params.getManager; - this.getSecurity = params.getSecurity; - this.getIdentity = params.getIdentity; - this.convert = params.convert; - } - - public async execute( - params: ILockEntryUseCaseExecuteParams - ): Promise { - let locked = false; - try { - locked = await this.isEntryLockedUseCase.execute(params); - } catch (ex) { - if (ex instanceof NotFoundError === false) { - throw ex; - } - locked = false; - } - if (locked) { - throw new WebinyError("Entry is already locked for editing.", "ENTRY_ALREADY_LOCKED", { - ...params - }); - } - const security = this.getSecurity(); - const identity = this.getIdentity(); - try { - const user: CmsIdentity = { - id: identity.id, - displayName: identity.displayName, - type: identity.type - }; - const manager = await this.getManager(); - - const id = createLockRecordDatabaseId(params.id); - return await security.withoutAuthorization(async () => { - const entry = await manager.create({ - id, - createdBy: user, - savedBy: user, - targetId: params.id, - type: params.type, - actions: [] - }); - return this.convert(entry); - }); - } catch (ex) { - throw new WebinyError( - `Could not lock entry: ${ex.message}`, - ex.code || "LOCK_ENTRY_ERROR", - { - ...ex.data - } - ); - } - } -} diff --git a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts b/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts deleted file mode 100644 index 98dc3f7a94b..00000000000 --- a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts +++ /dev/null @@ -1,121 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { - IUnlockEntryUseCase, - IUnlockEntryUseCaseExecuteParams -} from "~/abstractions/IUnlockEntryUseCase.js"; -import type { - IGetIdentity, - IHasRecordLockingAccessCallable, - IRecordLockingLockRecord, - IRecordLockingModelManager -} from "~/types.js"; -import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import { validateSameIdentity } from "~/utils/validateSameIdentity.js"; -import type { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase.js"; -import { NotFoundError } from "@webiny/handler-graphql"; -import type { Security } from "@webiny/api-core/types/security.js"; -import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; - -export interface IUnlockEntryUseCaseParams { - readonly getLockRecordUseCase: IGetLockRecordUseCase; - readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; - getManager(): Promise; - getSecurity(): Pick; - getIdentity: IGetIdentity; - hasRecordLockingAccess: IHasRecordLockingAccessCallable; -} - -export class UnlockEntryUseCase implements IUnlockEntryUseCase { - private readonly getLockRecordUseCase: IGetLockRecordUseCase; - private readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; - private readonly getManager: IUnlockEntryUseCaseParams["getManager"]; - private readonly getSecurity: IUnlockEntryUseCaseParams["getSecurity"]; - private readonly getIdentity: IGetIdentity; - private readonly hasRecordLockingAccess: IHasRecordLockingAccessCallable; - - public constructor(params: IUnlockEntryUseCaseParams) { - this.getLockRecordUseCase = params.getLockRecordUseCase; - this.kickOutCurrentUserUseCase = params.kickOutCurrentUserUseCase; - this.getManager = params.getManager; - this.getSecurity = params.getSecurity; - this.getIdentity = params.getIdentity; - this.hasRecordLockingAccess = params.hasRecordLockingAccess; - } - - public async execute( - params: IUnlockEntryUseCaseExecuteParams - ): Promise { - const record = await this.getLockRecordUseCase.execute(params); - if (!record || record.isExpired()) { - const security = this.getSecurity(); - try { - const manager = await this.getManager(); - await security.withoutAuthorization(async () => { - await manager.delete(createLockRecordDatabaseId(params.id), { - force: true, - permanently: true - }); - }); - } catch (ex) { - if (ex instanceof NotFoundError === false) { - console.log("Could not forcefully delete lock record."); - console.error(ex); - } - } - - throw new WebinyError("Lock Record not found.", "LOCK_RECORD_NOT_FOUND", { - ...params - }); - } - - /** - * We need to validate that the user executing unlock is the same user that locked the entry. - * In case it is not the same user, there is a possibility that it is a user which has full access, - * and at that point, we allow unlocking, but we also need to message the user who locked the entry. - * - */ - let kickOutCurrentUser = false; - try { - validateSameIdentity({ - getIdentity: this.getIdentity, - target: record.lockedBy - }); - } catch (ex) { - if (!params.force) { - throw ex; - } - const hasAccess = await this.hasRecordLockingAccess(); - if (ex instanceof NotAuthorizedError === false || !hasAccess) { - throw ex; - } - - kickOutCurrentUser = true; - } - - const security = this.getSecurity(); - try { - const manager = await this.getManager(); - return await security.withoutAuthorization(async () => { - await manager.delete(createLockRecordDatabaseId(params.id), { - force: true, - permanently: true - }); - - if (!kickOutCurrentUser) { - return record; - } - await this.kickOutCurrentUserUseCase.execute(record); - return record; - }); - } catch (ex) { - throw new WebinyError( - `Could not unlock entry: ${ex.message}`, - ex.code || "UNLOCK_ENTRY_ERROR", - { - ...ex.data - } - ); - } - } -} diff --git a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts b/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts deleted file mode 100644 index f30e550841b..00000000000 --- a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts +++ /dev/null @@ -1,114 +0,0 @@ -import WebinyError from "@webiny/error"; -import type { - IUnlockEntryRequestUseCase, - IUnlockEntryRequestUseCaseExecuteParams -} from "~/abstractions/IUnlockEntryRequestUseCase.js"; -import type { - IGetIdentity, - IRecordLockingLockRecord, - IRecordLockingModelManager -} from "~/types.js"; -import { RecordLockingLockRecordActionType } from "~/types.js"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; -import { createIdentifier } from "@webiny/utils"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; -import type { Security } from "@webiny/api-core/types/security.js"; - -export interface IUnlockEntryRequestUseCaseParams { - getLockRecordUseCase: IGetLockRecordUseCase; - getManager(): Promise; - getSecurity(): Pick; - getIdentity: IGetIdentity; - convert: ConvertEntryToLockRecordCb; -} - -export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { - private readonly getLockRecordUseCase: IGetLockRecordUseCase; - private readonly getManager: IUnlockEntryRequestUseCaseParams["getManager"]; - private readonly getSecurity: IUnlockEntryRequestUseCaseParams["getSecurity"]; - private readonly getIdentity: IGetIdentity; - private readonly convert: ConvertEntryToLockRecordCb; - - public constructor(params: IUnlockEntryRequestUseCaseParams) { - this.getLockRecordUseCase = params.getLockRecordUseCase; - this.getManager = params.getManager; - this.getSecurity = params.getSecurity; - this.getIdentity = params.getIdentity; - this.convert = params.convert; - } - - public async execute( - params: IUnlockEntryRequestUseCaseExecuteParams - ): Promise { - const record = await this.getLockRecordUseCase.execute(params); - if (!record || record.isExpired()) { - throw new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { - ...params - }); - } - const unlockRequested = record.getUnlockRequested(); - if (unlockRequested) { - const currentIdentity = this.getIdentity(); - /** - * If a current identity did not request unlock, we will not allow that user to continue. - */ - if (unlockRequested.createdBy.id !== currentIdentity.id) { - throw new WebinyError( - "Unlock request already sent.", - "UNLOCK_REQUEST_ALREADY_SENT", - { - ...params, - identity: unlockRequested.createdBy - } - ); - } - const approved = record.getUnlockApproved(); - const denied = record.getUnlockDenied(); - if (approved || denied) { - return record; - } - throw new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { - ...params, - identity: unlockRequested.createdBy - }); - } - - record.addAction({ - type: RecordLockingLockRecordActionType.requested, - createdOn: new Date(), - createdBy: this.getIdentity() - }); - - const security = this.getSecurity(); - - try { - const manager = await this.getManager(); - - const entryId = createLockRecordDatabaseId(record.id); - const id = createIdentifier({ - id: entryId, - version: 1 - }); - return await security.withoutAuthorization(async () => { - const result = await manager.update(id, record.toObject()); - return this.convert(result); - }); - } catch (ex) { - throw new WebinyError( - "Could not update record with a unlock request.", - "UNLOCK_REQUEST_ERROR", - { - ...ex.data, - error: { - message: ex.message, - code: ex.code - }, - id: params.id, - type: params.type, - recordId: record.id - } - ); - } - } -} diff --git a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts deleted file mode 100644 index 0cea6747348..00000000000 --- a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { - IUpdateEntryLockUseCase, - IUpdateEntryLockUseCaseExecuteParams -} from "~/abstractions/IUpdateEntryLockUseCase.js"; -import type { - IGetIdentity, - IRecordLockingLockRecord, - IRecordLockingModelManager -} from "~/types.js"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import { WebinyError } from "@webiny/error"; -import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId.js"; -import { createIdentifier } from "@webiny/utils"; -import { validateSameIdentity } from "~/utils/validateSameIdentity.js"; -import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase.js"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; -import type { Security } from "@webiny/api-core/types/security.js"; - -export interface IUpdateEntryLockUseCaseParams { - readonly getLockRecordUseCase: IGetLockRecordUseCase; - readonly lockEntryUseCase: ILockEntryUseCase; - getManager(): Promise; - getSecurity(): Pick; - getIdentity: IGetIdentity; - convert: ConvertEntryToLockRecordCb; -} - -export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase { - private readonly getLockRecordUseCase: IGetLockRecordUseCase; - private readonly lockEntryUseCase: ILockEntryUseCase; - private readonly getManager: IUpdateEntryLockUseCaseParams["getManager"]; - private readonly getSecurity: IUpdateEntryLockUseCaseParams["getSecurity"]; - private readonly getIdentity: IGetIdentity; - private readonly convert: ConvertEntryToLockRecordCb; - - public constructor(params: IUpdateEntryLockUseCaseParams) { - this.getLockRecordUseCase = params.getLockRecordUseCase; - this.lockEntryUseCase = params.lockEntryUseCase; - this.getManager = params.getManager; - this.getSecurity = params.getSecurity; - this.getIdentity = params.getIdentity; - this.convert = params.convert; - } - - public async execute( - params: IUpdateEntryLockUseCaseExecuteParams - ): Promise { - /** - * There is a possibility that the lock record already exists, just that the entry is not actually locked - lock expired. - */ - const record = await this.getLockRecordUseCase.execute(params); - /** - * If it exists, we will update the record with new user and dates. - * But if it does not exist, we will create a new record. - */ - if (!record) { - return this.lockEntryUseCase.execute(params); - } else if (record.isExpired()) { - return this.updateOverExistingLockRecord(record); - } - /** - * If the record exists and is not expired, we need to check if the user is the same as the one who locked it. - */ - validateSameIdentity({ - getIdentity: this.getIdentity, - target: record.lockedBy - }); - return this.updateExistingLockRecord(record); - } - - private async updateOverExistingLockRecord( - record: Pick - ): Promise { - try { - const manager = await this.getManager(); - const security = this.getSecurity(); - const identity = this.getIdentity(); - - const entryId = createLockRecordDatabaseId(record.id); - const id = createIdentifier({ - id: entryId, - version: 1 - }); - return await security.withoutAuthorization(async () => { - const date = new Date().toISOString(); - const result = await manager.update(id, { - savedOn: date, - createdOn: date, - savedBy: identity, - createdBy: identity - }); - return this.convert(result); - }); - } catch (ex) { - throw new WebinyError( - `Could not update lock entry: ${ex.message}`, - ex.code || "UPDATE_LOCK_ENTRY_ERROR", - { - ...ex.data - } - ); - } - } - - private async updateExistingLockRecord( - record: Pick - ): Promise { - try { - const manager = await this.getManager(); - const security = this.getSecurity(); - - const entryId = createLockRecordDatabaseId(record.id); - const id = createIdentifier({ - id: entryId, - version: 1 - }); - return await security.withoutAuthorization(async () => { - const result = await manager.update(id, { - savedOn: new Date().toISOString() - }); - return this.convert(result); - }); - } catch (ex) { - throw new WebinyError( - `Could not update lock entry: ${ex.message}`, - ex.code || "UPDATE_LOCK_ENTRY_ERROR", - { - ...ex.data - } - ); - } - } -} diff --git a/packages/api-record-locking/src/useCases/index.ts b/packages/api-record-locking/src/useCases/index.ts deleted file mode 100644 index d0964fe4e52..00000000000 --- a/packages/api-record-locking/src/useCases/index.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { - IGetIdentity, - IGetWebsocketsContextCallable, - IHasRecordLockingAccessCallable, - IRecordLockingModelManager -} from "~/types.js"; -import { GetLockRecordUseCase } from "./GetLockRecord/GetLockRecordUseCase.js"; -import { IsEntryLockedUseCase } from "./IsEntryLocked/IsEntryLockedUseCase.js"; -import { LockEntryUseCase } from "./LockEntryUseCase/LockEntryUseCase.js"; -import { UnlockEntryUseCase } from "./UnlockEntryUseCase/UnlockEntryUseCase.js"; -import { UnlockEntryRequestUseCase } from "./UnlockRequestUseCase/UnlockEntryRequestUseCase.js"; -import { ListAllLockRecordsUseCase } from "./ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.js"; -import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase/ListLockRecordsUseCase.js"; -import { UpdateEntryLockUseCase } from "~/useCases/UpdateEntryLock/UpdateEntryLockUseCase.js"; -import { KickOutCurrentUserUseCase } from "./KickOutCurrentUser/KickOutCurrentUserUseCase.js"; -import { GetLockedEntryLockRecordUseCase } from "~/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.js"; -import type { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase.js"; -import type { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase.js"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase.js"; -import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked.js"; -import type { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase.js"; -import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase.js"; -import type { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase.js"; -import type { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase.js"; -import type { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase.js"; -import { convertEntryToLockRecord as baseConvertEntryToLockRecord } from "~/utils/convertEntryToLockRecord.js"; -import type { ConvertEntryToLockRecordCb } from "~/useCases/types.js"; -import type { Security } from "@webiny/api-core/types/security.js"; - -export interface ICreateUseCasesParams { - getTimeout: () => number; - getIdentity: IGetIdentity; - getManager(): Promise; - getSecurity(): Pick; - hasRecordLockingAccess: IHasRecordLockingAccessCallable; - getWebsockets: IGetWebsocketsContextCallable; -} - -export interface ICreateUseCasesResponse { - listAllLockRecordsUseCase: IListAllLockRecordsUseCase; - listLockRecordsUseCase: IListLockRecordsUseCase; - getLockRecordUseCase: IGetLockRecordUseCase; - isEntryLockedUseCase: IIsEntryLockedUseCase; - getLockedEntryLockRecordUseCase: IGetLockedEntryLockRecordUseCase; - lockEntryUseCase: ILockEntryUseCase; - updateEntryLockUseCase: IUpdateEntryLockUseCase; - unlockEntryUseCase: IUnlockEntryUseCase; - unlockEntryRequestUseCase: IUnlockEntryRequestUseCase; -} - -export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesResponse => { - const { getTimeout } = params; - const timeout = getTimeout(); - - const convertEntryToLockRecord: ConvertEntryToLockRecordCb = entry => { - return baseConvertEntryToLockRecord(entry, timeout); - }; - - const listAllLockRecordsUseCase = new ListAllLockRecordsUseCase({ - getManager: params.getManager, - convert: convertEntryToLockRecord - }); - - const listLockRecordsUseCase = new ListLockRecordsUseCase({ - listAllLockRecordsUseCase, - timeout, - getIdentity: params.getIdentity - }); - - const getLockRecordUseCase = new GetLockRecordUseCase({ - getManager: params.getManager, - getSecurity: params.getSecurity, - convert: convertEntryToLockRecord - }); - - const isEntryLockedUseCase = new IsEntryLockedUseCase({ - getLockRecordUseCase, - getIdentity: params.getIdentity - }); - - const getLockedEntryLockRecordUseCase = new GetLockedEntryLockRecordUseCase({ - getLockRecordUseCase, - getIdentity: params.getIdentity - }); - - const lockEntryUseCase = new LockEntryUseCase({ - isEntryLockedUseCase, - getManager: params.getManager, - getSecurity: params.getSecurity, - getIdentity: params.getIdentity, - convert: convertEntryToLockRecord - }); - - const updateEntryLockUseCase = new UpdateEntryLockUseCase({ - getLockRecordUseCase, - lockEntryUseCase, - getManager: params.getManager, - getSecurity: params.getSecurity, - getIdentity: params.getIdentity, - convert: convertEntryToLockRecord - }); - - const kickOutCurrentUserUseCase = new KickOutCurrentUserUseCase({ - getWebsockets: params.getWebsockets, - getIdentity: params.getIdentity - }); - - const unlockEntryUseCase = new UnlockEntryUseCase({ - getLockRecordUseCase, - kickOutCurrentUserUseCase, - getManager: params.getManager, - getSecurity: params.getSecurity, - getIdentity: params.getIdentity, - hasRecordLockingAccess: params.hasRecordLockingAccess - }); - - const unlockEntryRequestUseCase = new UnlockEntryRequestUseCase({ - getLockRecordUseCase, - getIdentity: params.getIdentity, - getSecurity: params.getSecurity, - getManager: params.getManager, - convert: convertEntryToLockRecord - }); - - return { - listAllLockRecordsUseCase, - listLockRecordsUseCase, - getLockRecordUseCase, - isEntryLockedUseCase, - getLockedEntryLockRecordUseCase, - lockEntryUseCase, - updateEntryLockUseCase, - unlockEntryUseCase, - unlockEntryRequestUseCase - }; -}; diff --git a/packages/api-record-locking/src/useCases/types.ts b/packages/api-record-locking/src/useCases/types.ts deleted file mode 100644 index ed2a4bd5a93..00000000000 --- a/packages/api-record-locking/src/useCases/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { CmsEntry } from "@webiny/api-headless-cms/types/index.js"; -import type { IRecordLockingLockRecord, IRecordLockingLockRecordValues } from "~/types.js"; - -export interface ConvertEntryToLockRecordCb { - (entry: CmsEntry): IRecordLockingLockRecord; -} diff --git a/packages/api-record-locking/src/utils/calculateExpiresOn.ts b/packages/api-record-locking/src/utils/calculateExpiresOn.ts deleted file mode 100644 index 6b4401ae130..00000000000 --- a/packages/api-record-locking/src/utils/calculateExpiresOn.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { IHeadlessCmsLockRecordParams } from "./convertEntryToLockRecord.js"; - -export const calculateExpiresOn = ( - input: Pick, - timeout: number -): Date => { - if (!input.savedOn) { - throw new Error("Missing savedOn property."); - } - const savedOn = new Date(input.savedOn); - - return new Date(savedOn.getTime() + timeout); -}; diff --git a/packages/api-record-locking/src/utils/checkPermissions.ts b/packages/api-record-locking/src/utils/checkPermissions.ts deleted file mode 100644 index 140979f64ec..00000000000 --- a/packages/api-record-locking/src/utils/checkPermissions.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Context } from "~/types.js"; -import { NotAuthorizedError } from "~/utils/errors.js"; - -/** - * Simple permission check. Only full access can access the websockets API via GraphQL - ({name: "*"}) - * - * @throws - */ -export const checkPermissions = async (context: Pick): Promise => { - const identity = context.security.getIdentity(); - if (!identity.id) { - throw new NotAuthorizedError(); - } -}; diff --git a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts b/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts deleted file mode 100644 index d3c80efd417..00000000000 --- a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { - CmsEntry, - IRecordLockingIdentity, - IRecordLockingLockRecord, - IRecordLockingLockRecordAction, - IRecordLockingLockRecordApprovedAction, - IRecordLockingLockRecordDeniedAction, - IRecordLockingLockRecordEntryType, - IRecordLockingLockRecordObject, - IRecordLockingLockRecordRequestedAction, - IRecordLockingLockRecordValues -} from "~/types.js"; -import { RecordLockingLockRecordActionType } from "~/types.js"; -import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId.js"; -import { calculateExpiresOn } from "~/utils/calculateExpiresOn.js"; - -export const convertEntryToLockRecord = ( - entry: CmsEntry, - timeout: number -): IRecordLockingLockRecord => { - return new HeadlessCmsLockRecord(entry, timeout); -}; - -export type IHeadlessCmsLockRecordParams = Pick< - CmsEntry, - "entryId" | "values" | "createdBy" | "createdOn" | "savedOn" ->; - -export class HeadlessCmsLockRecord implements IRecordLockingLockRecord { - private readonly _id: string; - private readonly _targetId: string; - private readonly _type: IRecordLockingLockRecordEntryType; - private readonly _lockedBy: IRecordLockingIdentity; - private readonly _lockedOn: Date; - private readonly _updatedOn: Date; - private readonly _expiresOn: Date; - private _actions?: IRecordLockingLockRecordAction[]; - - public get id(): string { - return this._id; - } - - public get targetId(): string { - return this._targetId; - } - - public get type(): IRecordLockingLockRecordEntryType { - return this._type; - } - - public get lockedBy(): IRecordLockingIdentity { - return this._lockedBy; - } - - public get lockedOn(): Date { - return this._lockedOn; - } - - public get updatedOn(): Date { - return this._updatedOn; - } - - public get expiresOn(): Date { - return this._expiresOn; - } - - public get actions(): IRecordLockingLockRecordAction[] | undefined { - return this._actions; - } - - public constructor(input: IHeadlessCmsLockRecordParams, timeout: number) { - this._id = removeLockRecordDatabasePrefix(input.entryId); - this._targetId = input.values.targetId; - this._type = input.values.type; - this._lockedBy = input.createdBy; - this._lockedOn = new Date(input.createdOn); - this._updatedOn = new Date(input.savedOn); - this._expiresOn = calculateExpiresOn(input, timeout); - this._actions = input.values.actions; - } - - public toObject(): IRecordLockingLockRecordObject { - return { - id: this._id, - targetId: this._targetId, - type: this._type, - lockedBy: this._lockedBy, - lockedOn: this._lockedOn, - updatedOn: this._updatedOn, - expiresOn: this._expiresOn, - actions: this._actions - }; - } - - public addAction(action: IRecordLockingLockRecordAction) { - if (!this._actions) { - this._actions = []; - } - this._actions.push(action); - } - - public getUnlockRequested(): IRecordLockingLockRecordRequestedAction | undefined { - if (!this._actions?.length) { - return undefined; - } - return this._actions.find( - (action): action is IRecordLockingLockRecordRequestedAction => - action.type === RecordLockingLockRecordActionType.requested - ); - } - - public getUnlockApproved(): IRecordLockingLockRecordApprovedAction | undefined { - if (!this._actions?.length) { - return undefined; - } - return this._actions.find( - (action): action is IRecordLockingLockRecordApprovedAction => - action.type === RecordLockingLockRecordActionType.approved - ); - } - - public getUnlockDenied(): IRecordLockingLockRecordDeniedAction | undefined { - if (!this._actions?.length) { - return undefined; - } - return this._actions.find( - (action): action is IRecordLockingLockRecordDeniedAction => - action.type === RecordLockingLockRecordActionType.denied - ); - } - - public isExpired(): boolean { - return this._expiresOn.getTime() < new Date().getTime(); - } -} diff --git a/packages/api-record-locking/src/utils/errors.ts b/packages/api-record-locking/src/utils/errors.ts deleted file mode 100644 index fdedd7800aa..00000000000 --- a/packages/api-record-locking/src/utils/errors.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BaseError } from "@webiny/feature/api"; - -export class NotAuthorizedError extends BaseError { - override readonly code = "NOT_AUTHORIZED" as const; - - constructor(message?: string) { - super({ - message: message || "Not authorized!" - }); - } -} - -export class LockUpdateError extends BaseError { - override readonly code = "LOCK_UPDATE_ERROR" as const; - - constructor(message: string) { - super({ message }); - } -} diff --git a/packages/api-record-locking/src/utils/validateSameIdentity.ts b/packages/api-record-locking/src/utils/validateSameIdentity.ts deleted file mode 100644 index 13e68804221..00000000000 --- a/packages/api-record-locking/src/utils/validateSameIdentity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { IRecordLockingIdentity } from "~/types.js"; -import { LockUpdateError } from "~/utils/errors.js"; - -export interface IValidateSameIdentityParams { - getIdentity: () => Pick; - target: Pick; -} - -export const validateSameIdentity = (params: IValidateSameIdentityParams): void => { - const { getIdentity, target } = params; - const identity = getIdentity(); - if (identity.id === target.id) { - return; - } - - throw new LockUpdateError("Cannot update lock record. Record is locked by another user."); -}; diff --git a/packages/api-record-locking/tsconfig.build.json b/packages/api-record-locking/tsconfig.build.json index 1c54de5bc72..5a8028070cf 100644 --- a/packages/api-record-locking/tsconfig.build.json +++ b/packages/api-record-locking/tsconfig.build.json @@ -26,6 +26,9 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/error/*": ["../error/src/*"], diff --git a/packages/api-record-locking/tsconfig.json b/packages/api-record-locking/tsconfig.json index b4628277c5c..3f52e311bf9 100644 --- a/packages/api-record-locking/tsconfig.json +++ b/packages/api-record-locking/tsconfig.json @@ -26,6 +26,9 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/error/*": ["../error/src/*"], diff --git a/packages/api-scheduler/tsconfig.build.json b/packages/api-scheduler/tsconfig.build.json index b95adf1c7ec..c975ee9e0d6 100644 --- a/packages/api-scheduler/tsconfig.build.json +++ b/packages/api-scheduler/tsconfig.build.json @@ -5,11 +5,12 @@ { "path": "../api/tsconfig.build.json" }, { "path": "../api-core/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, + { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, - { "path": "../pubsub/tsconfig.build.json" }, + { "path": "../utils/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, @@ -152,6 +153,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], @@ -162,8 +165,8 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], "@webiny/handler/*": ["../handler/src/*"], diff --git a/packages/api-scheduler/tsconfig.json b/packages/api-scheduler/tsconfig.json index cc2ac6056c7..00b5c1ad465 100644 --- a/packages/api-scheduler/tsconfig.json +++ b/packages/api-scheduler/tsconfig.json @@ -5,11 +5,12 @@ { "path": "../api" }, { "path": "../api-core" }, { "path": "../api-headless-cms" }, + { "path": "../aws-sdk" }, { "path": "../error" }, { "path": "../feature" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, - { "path": "../pubsub" }, + { "path": "../utils" }, { "path": "../db-dynamodb" }, { "path": "../handler" }, { "path": "../handler-aws" }, @@ -152,6 +153,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], + "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], + "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], @@ -162,8 +165,8 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], + "@webiny/utils/*": ["../utils/src/*"], + "@webiny/utils": ["../utils/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], "@webiny/handler/*": ["../handler/src/*"], diff --git a/packages/api-websockets/src/index.ts b/packages/api-websockets/src/index.ts index cc3a266c84d..4e22679703c 100644 --- a/packages/api-websockets/src/index.ts +++ b/packages/api-websockets/src/index.ts @@ -14,5 +14,4 @@ export * from "./registry/index.js"; export * from "./context/index.js"; export * from "./plugins/index.js"; -export * from "./abstractions.js"; export type * from "./types.js"; diff --git a/packages/project-aws/tsconfig.build.json b/packages/project-aws/tsconfig.build.json index f0116ef5166..db1ae8956c8 100644 --- a/packages/project-aws/tsconfig.build.json +++ b/packages/project-aws/tsconfig.build.json @@ -247,6 +247,9 @@ "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/api-workflows/*": ["../api-workflows/src/*"], diff --git a/packages/project-aws/tsconfig.json b/packages/project-aws/tsconfig.json index c7cfacde8f7..fe1f5b37b23 100644 --- a/packages/project-aws/tsconfig.json +++ b/packages/project-aws/tsconfig.json @@ -247,6 +247,9 @@ "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/api-workflows/*": ["../api-workflows/src/*"], diff --git a/packages/testing/tsconfig.build.json b/packages/testing/tsconfig.build.json index ef1768d97bf..b5e6d423960 100644 --- a/packages/testing/tsconfig.build.json +++ b/packages/testing/tsconfig.build.json @@ -204,6 +204,9 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index 752e26b5790..3a92897cbc1 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -204,6 +204,9 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], + "@webiny/api-websockets/features/WebsocketsContext": [ + "../api-websockets/src/src/features/WebsocketsContext/index.js" + ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], From e0c1446c6d30979b7f98b3a803cee5de7f39c0e6 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 15:33:38 +0100 Subject: [PATCH 48/71] wip: migrate record locking --- .../__tests__/graphql/getLockRecord.test.ts | 2 +- .../__tests__/graphql/lockEntry.test.ts | 2 +- .../graphql/requestEntryUnlock.test.ts | 2 +- .../__tests__/graphql/unlockEntry.test.ts | 11 +- .../__tests__/graphql/updateEntryLock.test.ts | 9 +- .../__tests__/helpers/plugins.ts | 50 +------- .../__tests__/mocks/createConvert.ts | 9 -- .../__tests__/mocks/createGetIdentity.ts | 7 -- .../__tests__/mocks/createGetManager.ts | 7 -- .../__tests__/mocks/createGetSecurity.ts | 11 -- .../useCase/isEntryLockedUseCase.test.ts | 72 ----------- .../useCase/kickOutCurrentUser.test.ts | 53 -------- .../useCase/lockEntryUseCase.test.ts | 75 ------------ .../useCase/unlockEntryRequestUseCase.test.ts | 113 ------------------ .../useCase/unlockEntryUseCase.test.ts | 45 ------- .../utils/validateSameIdentity.test.ts | 47 -------- .../api-record-locking/src/domain/errors.ts | 11 +- .../GetLockRecord/GetLockRecordRepository.ts | 2 +- .../GetLockedEntryLockRecordUseCase.ts | 4 +- .../KickOutCurrentUserUseCase.ts | 10 +- .../features/LockEntry/LockEntryRepository.ts | 2 +- .../UnlockEntry/UnlockEntryRepository.ts | 2 +- .../UnlockEntry/UnlockEntryUseCase.ts | 8 +- .../src/features/UnlockEntry/abstractions.ts | 4 +- .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 4 +- .../features/UpdateEntryLock/abstractions.ts | 4 +- packages/api-record-locking/src/types.ts | 11 +- .../wcp/src/testing/createTestWcpLicense.ts | 8 +- 28 files changed, 52 insertions(+), 533 deletions(-) delete mode 100644 packages/api-record-locking/__tests__/mocks/createConvert.ts delete mode 100644 packages/api-record-locking/__tests__/mocks/createGetIdentity.ts delete mode 100644 packages/api-record-locking/__tests__/mocks/createGetManager.ts delete mode 100644 packages/api-record-locking/__tests__/mocks/createGetSecurity.ts delete mode 100644 packages/api-record-locking/__tests__/useCase/isEntryLockedUseCase.test.ts delete mode 100644 packages/api-record-locking/__tests__/useCase/kickOutCurrentUser.test.ts delete mode 100644 packages/api-record-locking/__tests__/useCase/lockEntryUseCase.test.ts delete mode 100644 packages/api-record-locking/__tests__/useCase/unlockEntryRequestUseCase.test.ts delete mode 100644 packages/api-record-locking/__tests__/useCase/unlockEntryUseCase.test.ts delete mode 100644 packages/api-record-locking/__tests__/utils/validateSameIdentity.test.ts diff --git a/packages/api-record-locking/__tests__/graphql/getLockRecord.test.ts b/packages/api-record-locking/__tests__/graphql/getLockRecord.test.ts index da31eea734e..d32df205028 100644 --- a/packages/api-record-locking/__tests__/graphql/getLockRecord.test.ts +++ b/packages/api-record-locking/__tests__/graphql/getLockRecord.test.ts @@ -16,7 +16,7 @@ describe("get lock record", () => { getLockRecord: { data: null, error: { - code: "NOT_FOUND", + code: "RecordLocking/LockRecord/NotFoundError", message: "Lock record not found.", data: null } diff --git a/packages/api-record-locking/__tests__/graphql/lockEntry.test.ts b/packages/api-record-locking/__tests__/graphql/lockEntry.test.ts index f091a4c4eea..b34c7db79cc 100644 --- a/packages/api-record-locking/__tests__/graphql/lockEntry.test.ts +++ b/packages/api-record-locking/__tests__/graphql/lockEntry.test.ts @@ -127,7 +127,7 @@ describe("lock entry", () => { lockEntry: { data: null, error: { - code: "ENTRY_ALREADY_LOCKED", + code: "RecordLocking/Entry/AlreadyLockedError", message: "Entry is already locked for editing.", data: { id: "someId#0001", diff --git a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts index 5b1af6775e7..05c01408566 100644 --- a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts +++ b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts @@ -92,7 +92,7 @@ describe("request entry unlock", () => { unlockEntryRequest: { data: null, error: { - code: "UNLOCK_REQUEST_ALREADY_SENT", + code: "RecordLocking/Entry/UnlockRequestAlreadySentError", data: { id: "someId#0001", type: "cms#author" diff --git a/packages/api-record-locking/__tests__/graphql/unlockEntry.test.ts b/packages/api-record-locking/__tests__/graphql/unlockEntry.test.ts index 883962404b5..b8890e5af7f 100644 --- a/packages/api-record-locking/__tests__/graphql/unlockEntry.test.ts +++ b/packages/api-record-locking/__tests__/graphql/unlockEntry.test.ts @@ -27,12 +27,9 @@ describe("unlock entry", () => { unlockEntry: { data: null, error: { - code: "LOCK_RECORD_NOT_FOUND", - data: { - id: "someId#0001", - type: "cms#author" - }, - message: "Lock Record not found." + code: "RecordLocking/LockRecord/NotFoundError", + message: "Lock record not found.", + data: null } } } @@ -139,7 +136,7 @@ describe("unlock entry", () => { getLockRecord: { data: null, error: { - code: "NOT_FOUND", + code: "RecordLocking/LockRecord/NotFoundError", data: null, message: "Lock record not found." } diff --git a/packages/api-record-locking/__tests__/graphql/updateEntryLock.test.ts b/packages/api-record-locking/__tests__/graphql/updateEntryLock.test.ts index a67b87c2851..518f2d09364 100644 --- a/packages/api-record-locking/__tests__/graphql/updateEntryLock.test.ts +++ b/packages/api-record-locking/__tests__/graphql/updateEntryLock.test.ts @@ -76,9 +76,12 @@ describe("update entry lock", () => { updateEntryLock: { data: null, error: { - code: "LOCK_UPDATE_ERROR", - data: null, - message: "Cannot update lock record. Record is locked by another user." + code: "RecordLocking/Identity/MismatchError", + data: { + currentId: "anotherUserId", + targetId: "id-12345678" + }, + message: "Identity mismatch - cannot perform action." } } } diff --git a/packages/api-record-locking/__tests__/helpers/plugins.ts b/packages/api-record-locking/__tests__/helpers/plugins.ts index 38a49dda659..ea6c25d139a 100644 --- a/packages/api-record-locking/__tests__/helpers/plugins.ts +++ b/packages/api-record-locking/__tests__/helpers/plugins.ts @@ -1,17 +1,15 @@ +import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense.js"; import graphQLHandlerPlugins from "@webiny/handler-graphql"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { createTenancyAndSecurity } from "./tenancySecurity"; -import { ContextPlugin } from "@webiny/api"; import type { Plugin, PluginCollection } from "@webiny/plugins/types"; import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; -import type { Context } from "~/types"; import type { PermissionsArg } from "./permissions"; import { createPermissions } from "./permissions"; import { createRecordLocking } from "~/index"; import type { IdentityData } from "@webiny/api-core/features/security/IdentityContext/index.js"; import { createApiCore } from "@webiny/api-core"; -import type { ApiKey } from "@webiny/api-core/types/security.js"; import apiKeyAuthentication from "@webiny/api-core/legacy/security/plugins/apiKeyAuthentication.js"; import apiKeyAuthorization from "@webiny/api-core/legacy/security/plugins/apiKeyAuthorization.js"; import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js"; @@ -43,25 +41,16 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { const apiCoreStorage = getStorageOps("apiCore"); const cmsStorage = getStorageOps("cms"); + const testProjectLicense = createTestWcpLicense({ recordLocking: true }); + return { storageOperations: cmsStorage.storageOperations, tenant, plugins: [ topPlugins, createApiCore({ - storageOperations: apiCoreStorage.storageOperations - }), - new ContextPlugin(async context => { - const wcp = context.wcp; - context.wcp.ensureCanUseFeature = featureId => { - if (featureId !== "recordLocking") { - return wcp.ensureCanUseFeature(featureId); - } - return true; - }; - context.wcp.canUseRecordLocking = () => { - return true; - }; + storageOperations: apiCoreStorage.storageOperations, + testProjectLicense }), ...cmsStorage.plugins, ...createTenancyAndSecurity({ @@ -69,35 +58,6 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { permissions: createPermissions(permissions), identity }), - { - type: "context", - name: "context-security-tenant", - async apply(context) { - context.security.getApiKeyByToken = async ( - token: string - ): Promise => { - if (!token || token !== "aToken") { - return null; - } - const apiKey = "a1234567890"; - return { - id: apiKey, - name: apiKey, - tenant: tenant.id, - permissions: identity?.permissions || [], - token, - createdBy: { - id: "test", - displayName: "test", - type: "admin" - }, - description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION - }; - }; - } - } as ContextPlugin, apiKeyAuthentication({ identityType: "api-key" }), apiKeyAuthorization({ identityType: "api-key" }), graphQLHandlerPlugins(), diff --git a/packages/api-record-locking/__tests__/mocks/createConvert.ts b/packages/api-record-locking/__tests__/mocks/createConvert.ts deleted file mode 100644 index 38e720f0f24..00000000000 --- a/packages/api-record-locking/__tests__/mocks/createConvert.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { convertEntryToLockRecord as baseConvertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; -import type { CmsEntry } from "@webiny/api-headless-cms/types"; -import type { IRecordLockingLockRecordValues } from "~/types"; - -export const createConvert = () => { - return (entry: CmsEntry) => { - return baseConvertEntryToLockRecord(entry, 20000); - }; -}; diff --git a/packages/api-record-locking/__tests__/mocks/createGetIdentity.ts b/packages/api-record-locking/__tests__/mocks/createGetIdentity.ts deleted file mode 100644 index 4578f88b115..00000000000 --- a/packages/api-record-locking/__tests__/mocks/createGetIdentity.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createIdentity } from "~tests/helpers/identity"; - -export const createGetIdentity = () => { - return () => { - return createIdentity(); - }; -}; diff --git a/packages/api-record-locking/__tests__/mocks/createGetManager.ts b/packages/api-record-locking/__tests__/mocks/createGetManager.ts deleted file mode 100644 index 21e23086834..00000000000 --- a/packages/api-record-locking/__tests__/mocks/createGetManager.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { IRecordLockingModelManager } from "~/types"; - -export const createGetManager = () => { - return async () => { - return {} as unknown as IRecordLockingModelManager; - }; -}; diff --git a/packages/api-record-locking/__tests__/mocks/createGetSecurity.ts b/packages/api-record-locking/__tests__/mocks/createGetSecurity.ts deleted file mode 100644 index b285b70b1c1..00000000000 --- a/packages/api-record-locking/__tests__/mocks/createGetSecurity.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { IIdentityContext } from "@webiny/api-core/features/IdentityContext"; - -export const createGetSecurity = () => { - return (): { withoutAuthorization: IIdentityContext["withoutAuthorization"] } => { - return { - withoutAuthorization: async cb => { - return await cb(); - } - }; - }; -}; diff --git a/packages/api-record-locking/__tests__/useCase/isEntryLockedUseCase.test.ts b/packages/api-record-locking/__tests__/useCase/isEntryLockedUseCase.test.ts deleted file mode 100644 index dd5267a11ed..00000000000 --- a/packages/api-record-locking/__tests__/useCase/isEntryLockedUseCase.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { IsEntryLockedUseCase } from "~/useCases/IsEntryLocked/IsEntryLockedUseCase"; -import { WebinyError } from "@webiny/error"; -import type { IRecordLockingLockRecord } from "~/types"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { createIdentity } from "~tests/helpers/identity"; - -describe("is entry locked use case", () => { - it("should return false if lock record is not found - object param", async () => { - const useCase = new IsEntryLockedUseCase({ - getLockRecordUseCase: { - async execute() { - throw new NotFoundError(); - } - }, - getIdentity: createIdentity - }); - - const result = await useCase.execute({ - id: "aTestId#0001", - type: "aTestType" - }); - - expect(result).toBe(false); - }); - - it("should return false if lock record is not locked", async () => { - const useCase = new IsEntryLockedUseCase({ - getLockRecordUseCase: { - async execute() { - return { - lockedOn: new Date("2020-01-01"), - lockedBy: createIdentity(), - isExpired() { - return false; - } - } as unknown as IRecordLockingLockRecord; - } - }, - getIdentity: createIdentity - }); - - const result = await useCase.execute({ - id: "aTestId#0001", - type: "aTestType" - }); - - expect(result).toBe(false); - }); - - it("should throw an error on error in getLockRecordUseCase", async () => { - expect.assertions(1); - - const useCase = new IsEntryLockedUseCase({ - getLockRecordUseCase: { - async execute() { - throw new WebinyError("Testing error.", "TESTING_ERROR"); - } - }, - getIdentity: createIdentity - }); - - try { - await useCase.execute({ - id: "aTestId#0001", - type: "aTestType" - }); - } catch (ex) { - expect(ex).toEqual(new WebinyError("Testing error.", "TESTING_ERROR")); - } - }); -}); diff --git a/packages/api-record-locking/__tests__/useCase/kickOutCurrentUser.test.ts b/packages/api-record-locking/__tests__/useCase/kickOutCurrentUser.test.ts deleted file mode 100644 index e4dcbb40ee3..00000000000 --- a/packages/api-record-locking/__tests__/useCase/kickOutCurrentUser.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi } from "vitest"; -import { KickOutCurrentUserUseCase } from "~/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase"; -import { IWebsocketsContextObject } from "@webiny/api-websockets"; -import { createIdentity } from "~tests/helpers/identity"; -import { IRecordLockingLockRecord } from "~/types"; - -describe("kick out current user", () => { - it("should send message via websockets to kick out current user", async () => { - const websocketsSend = vi.fn(async () => { - return; - }); - - const kickOutUserUseCase = new KickOutCurrentUserUseCase({ - getIdentity: () => { - return { - id: "identity-id", - displayName: "identity-display-name", - type: "identity-type" - }; - }, - getWebsockets: () => { - return { - send: websocketsSend - } as unknown as IWebsocketsContextObject; - } - }); - - const mockRecord = { - id: "aTestId#0001", - lockedOn: new Date("2020-01-01"), - lockedBy: createIdentity(), - toObject: () => { - return { - id: "aTestId#0001", - lockedOn: new Date("2020-01-01"), - lockedBy: createIdentity() - }; - } - } as unknown as IRecordLockingLockRecord; - - let error: Error | null = null; - try { - await kickOutUserUseCase.execute({ - ...mockRecord - }); - } catch (ex) { - error = ex; - } - expect(error).toBeNull(); - - expect(websocketsSend).toHaveBeenCalledOnce(); - }); -}); diff --git a/packages/api-record-locking/__tests__/useCase/lockEntryUseCase.test.ts b/packages/api-record-locking/__tests__/useCase/lockEntryUseCase.test.ts deleted file mode 100644 index aa5a8caca41..00000000000 --- a/packages/api-record-locking/__tests__/useCase/lockEntryUseCase.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { LockEntryUseCase } from "~/useCases/LockEntryUseCase/LockEntryUseCase"; -import { WebinyError } from "@webiny/error"; -import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; -import type { IRecordLockingModelManager } from "~/types"; -import { NotFoundError } from "@webiny/handler-graphql"; -import { createConvert } from "~tests/mocks/createConvert"; -import { createGetSecurity } from "~tests/mocks/createGetSecurity"; -import { createGetManager } from "~tests/mocks/createGetManager"; -import { createGetIdentity } from "~tests/mocks/createGetIdentity"; - -describe("lock entry use case", () => { - it("should throw an error on isEntryLockedUseCase.execute", async () => { - expect.assertions(1); - const useCase = new LockEntryUseCase({ - isEntryLockedUseCase: { - execute: async () => { - throw new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {}); - } - } as unknown as IIsEntryLockedUseCase, - getManager: createGetManager(), - getSecurity: createGetSecurity(), - convert: createConvert(), - getIdentity: createGetIdentity() - }); - try { - await useCase.execute({ - id: "id1", - type: "aType" - }); - } catch (ex) { - expect(ex).toEqual(new WebinyError("Trying out an error", "TRYING_OUT_ERROR", {})); - } - }); - - it("should throw an error on creating a lock record", async () => { - expect.assertions(1); - const useCase = new LockEntryUseCase({ - isEntryLockedUseCase: { - execute: async () => { - throw new NotFoundError(); - } - } as unknown as IIsEntryLockedUseCase, - getSecurity: createGetSecurity(), - convert: createConvert(), - getIdentity: createGetIdentity(), - getManager: async () => { - return { - create: async () => { - throw new WebinyError( - "Trying out an error on manager.create.", - "TRYING_OUT_ERROR", - {} - ); - } - } as unknown as IRecordLockingModelManager; - } - }); - - try { - await useCase.execute({ - id: "id1", - type: "aType" - }); - } catch (ex) { - expect(ex).toEqual( - new WebinyError( - "Could not lock entry: Trying out an error on manager.create.", - "TRYING_OUT_ERROR", - {} - ) - ); - } - }); -}); diff --git a/packages/api-record-locking/__tests__/useCase/unlockEntryRequestUseCase.test.ts b/packages/api-record-locking/__tests__/useCase/unlockEntryRequestUseCase.test.ts deleted file mode 100644 index 7745b2f86e6..00000000000 --- a/packages/api-record-locking/__tests__/useCase/unlockEntryRequestUseCase.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { UnlockEntryRequestUseCase } from "~/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; -import { getSecurityIdentity } from "~tests/helpers/identity"; -import { WebinyError } from "@webiny/error"; -import { createConvert } from "~tests/mocks/createConvert"; -import { createGetSecurity } from "~tests/mocks/createGetSecurity"; -import { createGetManager } from "~tests/mocks/createGetManager"; - -describe("unlock entry request use case", () => { - it("should throw an error on missing lock record", async () => { - expect.assertions(1); - const useCase = new UnlockEntryRequestUseCase({ - getLockRecordUseCase: { - execute: async () => { - return null; - } - } as unknown as IGetLockRecordUseCase, - getIdentity: getSecurityIdentity, - getManager: createGetManager(), - getSecurity: createGetSecurity(), - convert: createConvert() - }); - - try { - await useCase.execute({ id: "id", type: "type" }); - } catch (ex) { - expect(ex).toEqual( - new WebinyError("Entry is not locked.", "ENTRY_NOT_LOCKED", { - id: "id", - type: "type" - }) - ); - } - }); - - it("should throw an error if current user did not start the unlock request", async () => { - expect.assertions(1); - const useCase = new UnlockEntryRequestUseCase({ - getLockRecordUseCase: { - execute: async () => { - return { - getUnlockRequested() { - return { - createdBy: { - id: "other-user-id" - }, - isExpired() { - return false; - } - }; - }, - isExpired() { - return false; - } - }; - } - } as unknown as IGetLockRecordUseCase, - getIdentity: getSecurityIdentity, - getManager: createGetManager(), - getSecurity: createGetSecurity(), - convert: createConvert() - }); - - try { - await useCase.execute({ id: "id", type: "type" }); - } catch (ex) { - expect(ex).toEqual( - new WebinyError("Unlock request already sent.", "UNLOCK_REQUEST_ALREADY_SENT", { - id: "id", - type: "type", - identity: { - id: "other-user-id" - } - }) - ); - } - }); - - it("should return the lock record if unlock request was already approved", async () => { - expect.assertions(1); - const useCase = new UnlockEntryRequestUseCase({ - getLockRecordUseCase: { - execute: async () => { - return { - id: "wby-lm-aTestIdValue", - getUnlockRequested() { - return { - createdBy: getSecurityIdentity() - }; - }, - getUnlockApproved() { - return {}; - }, - getUnlockDenied() { - return null; - }, - isExpired() { - return false; - } - }; - } - } as unknown as IGetLockRecordUseCase, - getIdentity: getSecurityIdentity, - getManager: createGetManager(), - getSecurity: createGetSecurity(), - convert: createConvert() - }); - - const result = await useCase.execute({ id: "aTestIdValue#0001", type: "type" }); - expect(result.id).toEqual("wby-lm-aTestIdValue"); - }); -}); diff --git a/packages/api-record-locking/__tests__/useCase/unlockEntryUseCase.test.ts b/packages/api-record-locking/__tests__/useCase/unlockEntryUseCase.test.ts deleted file mode 100644 index a8785707ebb..00000000000 --- a/packages/api-record-locking/__tests__/useCase/unlockEntryUseCase.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { UnlockEntryUseCase } from "~/useCases/UnlockEntryUseCase/UnlockEntryUseCase"; -import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; -import { WebinyError } from "@webiny/error"; -import { createIdentity } from "~tests/helpers/identity"; -import { createGetSecurity } from "~tests/mocks/createGetSecurity"; - -describe("unlock entry use case", () => { - it("should throw an error on unlocking an entry", async () => { - expect.assertions(2); - - const useCase = new UnlockEntryUseCase({ - getLockRecordUseCase: { - execute: async () => { - return { - lockedBy: createIdentity(), - isExpired() { - return false; - } - }; - } - } as unknown as IGetLockRecordUseCase, - async getManager() { - throw new WebinyError("Testing error.", "TESTING_ERROR"); - }, - getIdentity: createIdentity, - kickOutCurrentUserUseCase: { - async execute(): Promise { - return; - } - }, - getSecurity: createGetSecurity(), - async hasRecordLockingAccess() { - return true; - } - }); - - try { - await useCase.execute({ id: "id", type: "type" }); - } catch (ex) { - expect(ex.message).toEqual("Could not unlock entry: Testing error."); - expect(ex.code).toEqual("TESTING_ERROR"); - } - }); -}); diff --git a/packages/api-record-locking/__tests__/utils/validateSameIdentity.test.ts b/packages/api-record-locking/__tests__/utils/validateSameIdentity.test.ts deleted file mode 100644 index e3af02b1fe9..00000000000 --- a/packages/api-record-locking/__tests__/utils/validateSameIdentity.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { WebinyError } from "@webiny/error"; -import { validateSameIdentity } from "~/utils/validateSameIdentity"; -import { createIdentity } from "~tests/helpers/identity"; - -describe("validate same identity", () => { - it("should throw an error on not matching identity", async () => { - expect.assertions(2); - - try { - validateSameIdentity({ - target: createIdentity(), - getIdentity: () => { - return createIdentity({ - id: "anotherId", - displayName: "some name", - type: "admin" - }); - } - }); - } catch (ex) { - expect(ex.message).toEqual( - "Cannot update lock record. Record is locked by another user." - ); - expect(ex.code).toEqual("LOCK_UPDATE_ERROR"); - } - }); - - it("should not throw an error on matching identity", async () => { - expect.assertions(0); - try { - validateSameIdentity({ - target: createIdentity(), - getIdentity: () => { - return createIdentity(); - } - }); - } catch (ex) { - expect(ex).toEqual( - new WebinyError({ - message: "Cannot update lock record. Record is locked by another user.", - code: "LOCK_UPDATE_ERROR" - }) - ); - } - }); -}); diff --git a/packages/api-record-locking/src/domain/errors.ts b/packages/api-record-locking/src/domain/errors.ts index d7423fccff7..7eb2cf48f33 100644 --- a/packages/api-record-locking/src/domain/errors.ts +++ b/packages/api-record-locking/src/domain/errors.ts @@ -11,13 +11,12 @@ export class EntryAlreadyLockedError extends BaseError<{ id: string; type: strin } } -export class LockRecordNotFoundError extends BaseError<{ id: string }> { +export class LockRecordNotFoundError extends BaseError { override readonly code = "RecordLocking/LockRecord/NotFoundError" as const; - constructor(data: { id: string }) { + constructor() { super({ - message: "Lock Record not found.", - data + message: "Lock record not found." }); } } @@ -32,8 +31,8 @@ export class LockRecordPersistenceError extends BaseError { } } -export class NotSameIdentityError extends BaseError<{ currentId: string; targetId: string }> { - override readonly code = "RecordLocking/Identity/NotSameError" as const; +export class IdentityMismatchError extends BaseError<{ currentId: string; targetId: string }> { + override readonly code = "RecordLocking/Identity/MismatchError" as const; constructor(data: { currentId: string; targetId: string }) { super({ diff --git a/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts index b868510bf54..470d10d5fe8 100644 --- a/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts +++ b/packages/api-record-locking/src/features/GetLockRecord/GetLockRecordRepository.ts @@ -27,7 +27,7 @@ class GetLockRecordRepositoryImpl implements RepositoryAbstraction.Interface { if (result.isFail()) { if (result.error.code === "Cms/Entry/NotFound") { - return Result.fail(new LockRecordNotFoundError({ id: entryId })); + return Result.fail(new LockRecordNotFoundError()); } return Result.fail(new LockRecordPersistenceError(result.error)); diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts index c3c6259cde5..e43464755b8 100644 --- a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts @@ -22,7 +22,7 @@ class GetLockedEntryLockRecordUseCaseImpl implements UseCaseAbstraction.Interfac // If not found or error, return not found error if (result.isFail()) { - return Result.fail(new LockRecordNotFoundError({ id: input.id })); + return Result.fail(new LockRecordNotFoundError()); } const record = result.value; @@ -34,7 +34,7 @@ class GetLockedEntryLockRecordUseCaseImpl implements UseCaseAbstraction.Interfac const lockedByCurrentUser = record.lockedBy.id === identity.id; if (record.isExpired() || lockedByCurrentUser) { - return Result.fail(new LockRecordNotFoundError({ id: input.id })); + return Result.fail(new LockRecordNotFoundError()); } // Locked by another user, return the record diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts index a42428a1c9a..30bd36791fe 100644 --- a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -7,11 +7,15 @@ import type { ILockRecord } from "~/domain/index.js"; class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { constructor( - private websocketsContext: WebsocketsContext.Interface, - private identityContext: IdentityContext.Interface + private identityContext: IdentityContext.Interface, + private websocketsContext?: WebsocketsContext.Interface ) {} async execute(record: ILockRecord): Promise> { + if (!this.websocketsContext) { + return Result.ok(); + } + const { lockedBy, id } = record; const { id: entryId } = parseIdentifier(id); @@ -44,5 +48,5 @@ class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { export const KickOutCurrentUserUseCase = UseCaseAbstraction.createImplementation({ implementation: KickOutCurrentUserUseCaseImpl, - dependencies: [WebsocketsContext, IdentityContext] + dependencies: [IdentityContext, [WebsocketsContext, { optional: true }]] }); diff --git a/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts b/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts index 8c6aee47d60..28232ee2ff9 100644 --- a/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts +++ b/packages/api-record-locking/src/features/LockEntry/LockEntryRepository.ts @@ -28,7 +28,7 @@ class LockEntryRepositoryImpl implements RepositoryAbstraction.Interface { const result = await this.createEntry.execute(this.model, { id, - values + ...values }); if (result.isFail()) { diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts index c3da6437bed..74061b0f634 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryRepository.ts @@ -26,7 +26,7 @@ class UnlockEntryRepositoryImpl implements RepositoryAbstraction.Interface { if (result.isFail()) { if (result.error.code === "Cms/Entry/NotFound") { - return Result.fail(new LockRecordNotFoundError({ id: entryId })); + return Result.fail(new LockRecordNotFoundError()); } return Result.fail(new UnlockEntryError(result.error)); } diff --git a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts index 2e20b493b7d..8c3890c0989 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/UnlockEntryUseCase.ts @@ -8,7 +8,7 @@ import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; import { KickOutCurrentUserUseCase } from "../KickOutCurrentUser/abstractions.js"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import type { ILockRecord } from "~/domain/LockRecord.js"; -import { LockRecordNotFoundError, NotSameIdentityError } from "~/domain/errors.js"; +import { LockRecordNotFoundError, IdentityMismatchError } from "~/domain/errors.js"; import { hasFullAccessPermission } from "./hasFullAccessPermission.js"; class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { @@ -38,7 +38,7 @@ class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { // If expired, cleanup and return error if (record.isExpired()) { await this.repository.delete(record.id); - const error = new LockRecordNotFoundError({ id: input.id }); + const error = new LockRecordNotFoundError(); return Result.fail(error); } @@ -51,7 +51,7 @@ class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { // If not the owner, check if force unlock is allowed if (!isSameUser) { if (!input.force) { - const error = new NotSameIdentityError({ + const error = new IdentityMismatchError({ currentId: identity.id, targetId: record.lockedBy.id }); @@ -61,7 +61,7 @@ class UnlockEntryUseCaseImpl implements UseCaseAbstraction.Interface { // Check if user has permission to force unlock const hasAccess = await hasFullAccessPermission(this.identityContext); if (!hasAccess) { - const error = new NotSameIdentityError({ + const error = new IdentityMismatchError({ currentId: identity.id, targetId: record.lockedBy.id }); diff --git a/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts b/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts index e2dfce5fe6c..9609aae4e2a 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/abstractions.ts @@ -5,7 +5,7 @@ import type { LockRecordEntryType } from "~/domain/types.js"; import { type LockRecordNotFoundError, LockRecordPersistenceError, - type NotSameIdentityError, + type IdentityMismatchError, type UnlockEntryError } from "~/domain/errors.js"; @@ -25,7 +25,7 @@ export interface IUnlockEntryUseCase { export interface IUnlockEntryUseCaseErrors { notFound: LockRecordNotFoundError; - notSameIdentity: NotSameIdentityError; + notSameIdentity: IdentityMismatchError; unlockError: UnlockEntryError; persistence: LockRecordPersistenceError; } diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts index b87a2678e2c..5427cfdafc5 100644 --- a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts +++ b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts @@ -8,7 +8,7 @@ import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; import { LockEntryUseCase } from "../LockEntry/abstractions.js"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import type { ILockRecord } from "~/domain/LockRecord.js"; -import { LockRecordNotFoundError, NotSameIdentityError, UpdateEntryLockError } from "~/domain/errors.js"; +import { LockRecordNotFoundError, IdentityMismatchError, UpdateEntryLockError } from "~/domain/errors.js"; class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -49,7 +49,7 @@ class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { const identity = this.identityContext.getIdentity(); if (record.lockedBy.id !== identity.id) { return Result.fail( - new NotSameIdentityError({ + new IdentityMismatchError({ currentId: identity.id, targetId: record.lockedBy.id }) diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts index 4c7f1a1b61c..55bc69ec4ae 100644 --- a/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts +++ b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts @@ -2,7 +2,7 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { ILockRecord } from "~/domain/LockRecord.js"; import type { LockRecordEntryType } from "~/domain/types.js"; -import type { LockRecordNotFoundError, LockRecordPersistenceError, NotSameIdentityError, UpdateEntryLockError } from "~/domain/errors.js"; +import type { LockRecordNotFoundError, LockRecordPersistenceError, IdentityMismatchError, UpdateEntryLockError } from "~/domain/errors.js"; // Input types export interface UpdateEntryLockInput { @@ -19,7 +19,7 @@ export interface IUpdateEntryLockUseCase { export interface IUpdateEntryLockUseCaseErrors { notFound: LockRecordNotFoundError; - notSameIdentity: NotSameIdentityError; + notSameIdentity: IdentityMismatchError; persistence: LockRecordPersistenceError; updateError: UpdateEntryLockError; } diff --git a/packages/api-record-locking/src/types.ts b/packages/api-record-locking/src/types.ts index 44401f139eb..edddd864745 100644 --- a/packages/api-record-locking/src/types.ts +++ b/packages/api-record-locking/src/types.ts @@ -1,8 +1,7 @@ import type { CmsEntryListParams, CmsEntryMeta, - CmsIdentity, - CmsModelManager + CmsIdentity } from "@webiny/api-headless-cms/types/index.js"; import { CmsEntry, CmsError } from "@webiny/api-headless-cms/types/index.js"; @@ -10,15 +9,8 @@ export type { CmsError, CmsEntry }; export type IRecordLockingIdentity = CmsIdentity; -export type IRecordLockingModelManager = CmsModelManager; - export type IRecordLockingMeta = CmsEntryMeta; -export interface IRecordLockingLockRecordValues { - targetId: string; - type: IRecordLockingLockRecordEntryType; - actions?: IRecordLockingLockRecordAction[]; -} export enum RecordLockingLockRecordActionType { requested = "requested", approved = "approved", @@ -82,4 +74,3 @@ export type IRecordLockingListAllLockRecordsParams = Pick< >; export type IRecordLockingListLockRecordsParams = IRecordLockingListAllLockRecordsParams; - diff --git a/packages/wcp/src/testing/createTestWcpLicense.ts b/packages/wcp/src/testing/createTestWcpLicense.ts index 5605b5c26ab..4be19de229f 100644 --- a/packages/wcp/src/testing/createTestWcpLicense.ts +++ b/packages/wcp/src/testing/createTestWcpLicense.ts @@ -1,7 +1,11 @@ import type { DecryptedWcpProjectLicense } from "~/types.js"; import { MT_OPTIONS_MAX_COUNT_TYPE, PROJECT_PACKAGE_FEATURE_NAME } from "~/types.js"; -export const createTestWcpLicense = (): DecryptedWcpProjectLicense => { +interface LicenseOptions { + recordLocking?: boolean; +} + +export const createTestWcpLicense = (options?: LicenseOptions): DecryptedWcpProjectLicense => { return { orgId: "org-id", projectId: "project-id", @@ -30,7 +34,7 @@ export const createTestWcpLicense = (): DecryptedWcpProjectLicense => { enabled: false }, [PROJECT_PACKAGE_FEATURE_NAME.RECORD_LOCKING]: { - enabled: false + enabled: options?.recordLocking ?? false }, [PROJECT_PACKAGE_FEATURE_NAME.SEATS]: { enabled: true, From 7a4c0de25c8391597aeb89dd88ff8ef128be1305 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 15:35:22 +0100 Subject: [PATCH 49/71] wip: remove obsolete files --- .../ARCHITECTURE.md | 497 ------- packages/api-headless-cms/MIGRATION_PLAN.md | 1288 ----------------- packages/api-record-locking/MIGRATION_PLAN.md | 530 ------- yarn.lock | 9 +- 4 files changed, 6 insertions(+), 2318 deletions(-) delete mode 100644 packages/api-headless-cms-scheduler/ARCHITECTURE.md delete mode 100644 packages/api-headless-cms/MIGRATION_PLAN.md delete mode 100644 packages/api-record-locking/MIGRATION_PLAN.md diff --git a/packages/api-headless-cms-scheduler/ARCHITECTURE.md b/packages/api-headless-cms-scheduler/ARCHITECTURE.md deleted file mode 100644 index 05128fddc48..00000000000 --- a/packages/api-headless-cms-scheduler/ARCHITECTURE.md +++ /dev/null @@ -1,497 +0,0 @@ -# API Headless CMS Scheduler - Architecture Documentation - -## Overview - -The `@webiny/api-headless-cms-scheduler` package provides scheduling capabilities for Webiny Headless CMS, allowing users to schedule publish and unpublish operations for CMS entries at specific future dates. The package integrates with AWS EventBridge Scheduler to execute scheduled actions via Lambda invocations. - -## Key Entry Points - -### Main Export (`src/index.ts`) - -**`createHeadlessCmsScheduler(params)`** - Primary entry point that registers all necessary plugins: -- Handler plugin for processing scheduled CMS action events -- API plugins for GraphQL operations and context management -- Model definition for storing schedule records - -```typescript -createHeadlessCmsScheduler({ - getClient: (config) => schedulerClient -}) -``` - -## Core Architecture Components - -### 1. Context Layer (`src/context.ts`) - -**Purpose**: Initializes and attaches the scheduler to the CMS context. - -**Key Responsibilities**: -- Validates Headless CMS is ready -- Loads manifest from DynamoDB to get Lambda ARN and IAM role -- Creates `SchedulerService` with AWS credentials -- Attaches `scheduler` callable to `context.cms` -- Registers lifecycle hooks - -**Flow**: `context.ts:22-77` -1. Check if CMS is installed and ready -2. Load scheduler manifest (Lambda ARN, Role ARN) from DynamoDB -3. Get scheduler model (`webinyCmsSchedule`) -4. Attach lifecycle hooks -5. Create and attach scheduler factory to context - -### 2. Scheduler Layer (`src/scheduler/`) - -#### Scheduler Factory (`createScheduler.ts`) - -**Purpose**: Factory function that creates model-specific scheduler instances. - -**Signature**: `(targetModel: CmsModel) => IScheduler` - -**Key Components Created**: -- `ScheduleFetcher` - Retrieves schedule records -- `PublishScheduleAction` - Handles publish scheduling -- `UnpublishScheduleAction` - Handles unpublish scheduling -- `ScheduleExecutor` - Coordinates action execution -- `Scheduler` - Main scheduler interface - -#### Scheduler Class (`Scheduler.ts`) - -**Purpose**: Main scheduler interface implementation using composition pattern. - -**Methods**: -- `schedule(targetId, input)` - Create/update a schedule -- `cancel(id)` - Cancel existing schedule -- `getScheduled(targetId)` - Get schedule for entry -- `listScheduled(params)` - List schedules with filtering - -#### ScheduleExecutor (`ScheduleExecutor.ts`) - -**Purpose**: Executes scheduling operations by delegating to appropriate action classes. - -**Key Methods**: -- `schedule(targetId, input)`: Creates new schedule or reschedules existing - - Generates schedule record ID from target ID - - Checks for existing schedule - - Delegates to appropriate action (Publish/Unpublish) -- `cancel(id)`: Cancels schedule by ID -- `getAction(type)`: Returns appropriate action handler - -#### ScheduleFetcher (`ScheduleFetcher.ts`) - -**Purpose**: Retrieves schedule records from CMS. - -**Key Methods**: -- `getScheduled(targetId)`: Fetches single schedule record -- `listScheduled(params)`: Lists schedules with filtering/pagination - -**Note**: Always filters by target model ID to ensure model isolation. - -#### Schedule Actions - -##### PublishScheduleAction (`actions/PublishScheduleAction.ts`) - -**Purpose**: Handles publish scheduling logic. - -**Key Methods**: - -1. **`schedule(params)`** - Three execution paths: - - **Immediate publish**: Publishes entry immediately, no schedule created - - **Past date**: Updates entry metadata with custom dates, then publishes - - **Future date**: Creates schedule entry in CMS + AWS EventBridge schedule - -2. **`reschedule(original, input)`** - Updates existing schedule: - - If immediate or past date: publishes and cancels schedule - - Otherwise: updates schedule entry and EventBridge schedule - -3. **`cancel(id)`** - Deletes schedule entry and EventBridge schedule - -**Error Handling**: If EventBridge schedule creation fails, deletes the CMS schedule entry (rollback). - -##### UnpublishScheduleAction (`actions/UnpublishScheduleAction.ts`) - -**Purpose**: Similar to PublishScheduleAction but for unpublish operations. - -### 3. Service Layer (`src/service/`) - -#### SchedulerService (`SchedulerService.ts`) - -**Purpose**: Wrapper around AWS EventBridge Scheduler SDK. - -**Key Methods**: -- `create(params)`: Creates EventBridge schedule - - Validates date is in future - - Auto-updates if schedule exists - - Creates one-time schedule with Lambda target -- `update(params)`: Updates existing schedule -- `delete(id)`: Deletes schedule from EventBridge -- `exists(id)`: Checks if schedule exists - -**Schedule Configuration**: -- Expression: `at(YYYY-MM-DDTHH:mm:ss)` format -- Action after completion: DELETE (auto-cleanup) -- Flexible time window: OFF (exact time execution) -- Target: Lambda function with schedule event payload - -**Payload Format**: -```json -{ - "WebinyScheduledCmsAction": { - "id": "schedule-record-id", - "scheduleOn": "2024-01-01T12:00:00.000Z" - } -} -``` - -### 4. Handler Layer (`src/handler/`) - -#### Handler (`Handler.ts`) - -**Purpose**: Processes events from AWS EventBridge Scheduler when schedules execute. - -**Execution Flow**: `Handler.ts:33-105` - -1. Extract schedule ID from event payload -2. Fetch schedule entry from CMS (without authorization) -3. Set identity to the user who scheduled the action -4. Get target model and schedule record -5. Find appropriate handler action (Publish/Unpublish) -6. Execute the action -7. Delete schedule entry on success -8. Update schedule entry with error on failure - -#### Handler Actions - -##### PublishHandlerAction (`actions/PublishHandlerAction.ts`) - -**Purpose**: Executes scheduled publish operations. - -**Logic**: `PublishHandlerAction.ts:30-74` -1. Fetch target entry -2. Check if already published: - - **Not published**: Publish entry - - **Same revision published**: Republish (idempotent) - - **Different revision published**: Unpublish old, publish new - -##### UnpublishHandlerAction (`actions/UnpublishHandlerAction.ts`) - -**Purpose**: Executes scheduled unpublish operations. - -**Logic**: `UnpublishHandlerAction.ts:25-58` -1. Fetch target entry -2. Check publish status: - - **Not published**: Do nothing (log warning) - - **Exact match published**: Unpublish - - **Different revision published**: Unpublish published revision - -#### Event Handler Registration (`handler/index.ts`) - -Registers handler with AWS event system using `createEventHandler`: -- Event identification: Checks for `WebinyScheduledCmsAction` property -- Handler factory: Creates Handler with Publish/Unpublish actions -- Integration: Registered in handler-aws registry - -### 5. GraphQL Layer (`src/graphql/`) - -#### Schema Definition (`graphql/index.ts`) - -**Queries**: -- `getCmsSchedule(modelId, id)`: Get single schedule -- `listCmsSchedules(modelId, where, sort, limit, after)`: List schedules - -**Mutations**: -- `createCmsSchedule(modelId, id, input)`: Create schedule -- `updateCmsSchedule(modelId, id, input)`: Update schedule -- `cancelCmsSchedule(modelId, id)`: Cancel schedule - -**Input Validation**: Uses Zod schemas (`graphql/schema.ts`) for runtime validation - -### 6. Data Model Layer (`src/scheduler/model.ts`) - -**Model ID**: `webinyCmsSchedule` - -**Fields**: -- `targetId`: ID of the entry to schedule (with version) -- `targetModelId`: Model ID of target entry -- `scheduledBy`: Identity object (id, displayName, type) -- `scheduledOn`: DateTime when action should execute -- `dateOn`: DateTime for custom date metadata (currently unused) -- `type`: "publish" or "unpublish" -- `title`: Title of target entry -- `error`: Error message if execution failed - -**Type**: Private model (not exposed to public GraphQL API) - -### 7. Lifecycle Hooks (`src/hooks/index.ts`) - -**Purpose**: Auto-cleanup when entries are manually published/unpublished/deleted. - -**Hooks Registered**: -- `onEntryAfterPublish`: Cancel publish schedule -- `onEntryAfterUnpublish`: Cancel unpublish schedule -- `onEntryAfterDelete`: Cancel any schedule - -**Reasoning**: If user performs action manually, scheduled action becomes obsolete. - -### 8. Manifest System (`src/manifest.ts`) - -**Purpose**: Loads scheduler configuration from DynamoDB Service Discovery. - -**Schema**: -```typescript -{ - scheduler: { - lambdaArn: string, // ARN of handler Lambda - roleArn: string // IAM role for EventBridge - } -} -``` - -**Error Handling**: Returns error object if manifest missing or invalid. - -## Data Flow Diagrams - -### Creating a Schedule - -``` -GraphQL Mutation (createCmsSchedule) - ↓ -Validation (Zod schema) - ↓ -Get target model - ↓ -Get scheduler for model (context.cms.scheduler(model)) - ↓ -scheduler.schedule(targetId, input) - ↓ -ScheduleExecutor.schedule() - ↓ -ScheduleAction.schedule() (Publish/Unpublish) - ↓ -├─ Immediate? → Publish/Unpublish entry directly -├─ Past date? → Update metadata + Publish/Unpublish -└─ Future date? - ↓ - Create CMS entry (webinyCmsSchedule) - ↓ - SchedulerService.create() - ↓ - AWS EventBridge Scheduler (creates schedule) -``` - -### Executing a Schedule - -``` -AWS EventBridge Scheduler (triggers at scheduled time) - ↓ -Lambda invocation with payload - ↓ -Handler.handle() - ↓ -Fetch schedule entry (bypass authorization) - ↓ -Set identity to scheduler - ↓ -Get target model & entry - ↓ -Find handler action (Publish/Unpublish) - ↓ -HandlerAction.handle() - ↓ -├─ PublishHandlerAction: Check if published → Publish/Republish -└─ UnpublishHandlerAction: Check if published → Unpublish - ↓ -Delete schedule entry (cleanup) -``` - -### Canceling a Schedule - -``` -GraphQL Mutation (cancelCmsSchedule) - ↓ -scheduler.cancel(id) - ↓ -ScheduleExecutor.cancel() - ↓ -Fetch existing schedule - ↓ -ScheduleAction.cancel() - ↓ -Delete CMS entry (webinyCmsSchedule) - ↓ -SchedulerService.delete() - ↓ -AWS EventBridge Scheduler (delete schedule) -``` - -## Important Constants (`src/constants.ts`) - -- `SCHEDULE_MODEL_ID`: "webinyCmsSchedule" - CMS model for schedules -- `SCHEDULE_ID_PREFIX`: "wby-schedule-" - Prefix for schedule IDs -- `SCHEDULE_MIN_FUTURE_SECONDS`: 65 - Minimum seconds in future for scheduling -- `SCHEDULED_CMS_ACTION_EVENT_IDENTIFIER`: "WebinyScheduledCmsAction" - Event type identifier - -## Key Design Patterns - -### 1. Factory Pattern -- `createScheduler()` creates model-specific scheduler instances -- Each model gets isolated scheduler with its own actions - -### 2. Strategy Pattern -- `IScheduleAction` interface with Publish/Unpublish implementations -- `IHandlerAction` interface with Publish/Unpublish handlers -- Actions selected based on schedule type - -### 3. Composition Pattern -- `Scheduler` composes `ScheduleFetcher` and `ScheduleExecutor` -- `ScheduleExecutor` composes multiple `ScheduleAction` instances - -### 4. Repository Pattern -- `ScheduleFetcher` abstracts data retrieval -- `SchedulerService` abstracts AWS EventBridge operations - -## Security Considerations - -1. **Authorization Bypass**: Handler runs `withoutAuthorization()` to fetch schedule entries -2. **Identity Impersonation**: Handler sets identity to original scheduler for proper permissions -3. **Model Isolation**: Schedulers only operate on their assigned model -4. **Private Model**: Schedule model not exposed to public API - -## Error Handling Strategy - -1. **Schedule Creation Failure**: Rollback CMS entry if EventBridge fails -2. **Execution Failure**: Update schedule entry with error message -3. **Missing Schedules**: Return null for not-found schedules -4. **Manifest Errors**: Log and skip scheduler attachment entirely - -## Dependencies - -### Runtime -- `@webiny/api-headless-cms`: CMS core functionality -- `@webiny/aws-sdk`: AWS EventBridge Scheduler client -- `@webiny/handler-graphql`: GraphQL resolvers -- `zod`: Schema validation - -### Infrastructure -- AWS EventBridge Scheduler -- AWS Lambda (for handler) -- IAM Role (for EventBridge to invoke Lambda) -- DynamoDB (for manifest storage) - -## Extension Points - -### Adding New Schedule Types - -1. Create new `ScheduleAction` implementing `IScheduleAction` -2. Create new `HandlerAction` implementing `IHandlerAction` -3. Add to actions array in `createScheduler()` and `createScheduledCmsActionEventHandler()` -4. Update `ScheduleType` enum in `types.ts` -5. Update GraphQL schema - -### Custom Schedule Validation - -Override or extend Zod schemas in `graphql/schema.ts` - -### Additional Lifecycle Hooks - -Add hooks in `hooks/index.ts` using CMS lifecycle events - -## Testing Strategy - -Tests are organized by layer: -- `/scheduler/` - Scheduler, Executor, Fetcher, Actions -- `/handler/` - Handler and Handler Actions -- `/service/` - SchedulerService (uses aws-sdk-client-mock) -- `/graphql/` - GraphQL schema validation - -## File Structure Summary - -``` -src/ -├── index.ts # Main entry point -├── context.ts # Context plugin setup -├── types.ts # Type definitions for context -├── constants.ts # Global constants -├── manifest.ts # Manifest loader -├── graphql/ -│ ├── index.ts # GraphQL plugin -│ └── schema.ts # Zod validation schemas -├── scheduler/ -│ ├── createScheduler.ts # Scheduler factory -│ ├── Scheduler.ts # Main scheduler class -│ ├── ScheduleExecutor.ts # Execution coordinator -│ ├── ScheduleFetcher.ts # Data retrieval -│ ├── ScheduleRecord.ts # Record transformations -│ ├── model.ts # CMS model definition -│ ├── types.ts # Scheduler types -│ ├── dates.ts # Date utilities -│ ├── createScheduleRecordId.ts # ID generation -│ └── actions/ -│ ├── PublishScheduleAction.ts -│ └── UnpublishScheduleAction.ts -├── handler/ -│ ├── index.ts # Event handler registration -│ ├── Handler.ts # Main handler class -│ ├── types.ts # Handler types -│ └── actions/ -│ ├── PublishHandlerAction.ts -│ └── UnpublishHandlerAction.ts -├── service/ -│ ├── SchedulerService.ts # AWS EventBridge wrapper -│ └── types.ts # Service types -├── hooks/ -│ └── index.ts # Lifecycle hooks -└── utils/ - └── dateInTheFuture.ts # Date validation -``` - -## Common Use Cases - -### 1. Schedule Entry Publication -User wants to publish a blog post at a future date: -- User creates draft entry -- Calls `createCmsSchedule` with future date and type="publish" -- System creates schedule in CMS and EventBridge -- At scheduled time, entry is published automatically - -### 2. Scheduled Unpublish -User wants to unpublish content after campaign ends: -- User has published entry -- Calls `createCmsSchedule` with future date and type="unpublish" -- At scheduled time, entry is unpublished - -### 3. Reschedule Operation -User wants to change publication date: -- Calls `updateCmsSchedule` with new date -- System updates both CMS entry and EventBridge schedule - -### 4. Manual Override -User manually publishes scheduled entry: -- `onEntryAfterPublish` hook triggers -- System cancels schedule automatically -- EventBridge schedule is deleted - -## Performance Considerations - -1. **Pagination**: List operations support cursor-based pagination -2. **Model Filtering**: Queries filtered by model ID at database level -3. **Lazy Loading**: Scheduler created per-model on-demand -4. **Auto-cleanup**: EventBridge schedules auto-delete after execution - -## Troubleshooting - -### Schedule Not Executing -1. Check schedule entry exists in CMS (`webinyCmsSchedule`) -2. Verify EventBridge schedule exists (use AWS console or `SchedulerService.exists()`) -3. Check Lambda execution logs for handler errors -4. Verify IAM role has Lambda invoke permissions - -### Schedule Entry Has Error Field -Check `error` field in schedule entry for execution failure details - -### Scheduler Not Available -1. Check manifest loaded successfully (logs on startup) -2. Verify `webinyCmsSchedule` model exists -3. Ensure CMS is fully installed - -### Date Validation Errors -Verify date is at least 65 seconds in future (`SCHEDULE_MIN_FUTURE_SECONDS`) \ No newline at end of file diff --git a/packages/api-headless-cms/MIGRATION_PLAN.md b/packages/api-headless-cms/MIGRATION_PLAN.md deleted file mode 100644 index 78f037ddb4e..00000000000 --- a/packages/api-headless-cms/MIGRATION_PLAN.md +++ /dev/null @@ -1,1288 +0,0 @@ -# API Headless CMS - Clean Architecture Migration Plan - -## Executive Summary - -This document outlines the migration plan for `@webiny/api-headless-cms` from its current CRUD-based architecture to a Clean Architecture with domain-driven design (DDD), following the patterns established in `@webiny/api-core`. - -**Migration Goals:** -1. ✅ Break down monolithic CRUD files into domain-specific features -2. ✅ Implement proper use cases with dependency injection -3. ✅ Create unified repository pattern for DB + code-defined models -4. ✅ Establish domain events with proper event handlers -5. ✅ Maintain backward compatibility during migration -6. ⚠️ Keep model plugins in legacy format (out of scope for Phase 1) - ---- - -## Current Architecture Analysis - -### Package Structure (Before Migration) - -``` -packages/api-headless-cms/src/ -├── crud/ -│ ├── contentModel.crud.ts # 800+ lines - Model CRUD -│ ├── contentModelGroup.crud.ts # 400+ lines - Group CRUD -│ ├── contentEntry.crud.ts # 1800+ lines - Entry CRUD orchestrator -│ └── contentEntry/ -│ ├── useCases/ # 47 use case files (already structured!) -│ ├── beforeCreate.ts # Lifecycle hooks -│ ├── beforeUpdate.ts -│ └── entryDataFactories/ # Data transformation -├── types/ -│ ├── types.ts # 2400+ lines of type definitions -│ ├── context.ts # Context interfaces -│ └── plugins.ts # Plugin types -├── plugins/ -│ ├── CmsModelPlugin.ts # Code-defined models -│ ├── CmsGroupPlugin.ts # Code-defined groups -│ └── ... # Field type plugins -├── storage/ # Storage transform plugins -├── utils/ # Utilities and helpers -└── graphql/ # GraphQL schema generation -``` - -### Identified Problems - -1. **Monolithic CRUD files** - contentEntry.crud.ts is 1800+ lines -2. **Mixed responsibilities** - CRUD files handle orchestration, validation, events, transforms -3. **No domain boundaries** - Publishing, deletion, revisions all mixed together -4. **Dual model sources** - DB models and plugin models handled inconsistently -5. **Event system fragmentation** - Both pub/sub topics AND DI-based hooks exist -6. **Storage operations exposed** - Direct storage calls throughout codebase - ---- - -## Target Architecture (After Migration) - -### Domain Identification - -Based on analysis, we've identified **3 primary domains** with clear boundaries: - -#### 1. **Content Models Domain** -Management of content model definitions (schemas). - -**Responsibilities:** -- Define and validate model schemas -- Manage model lifecycle (create, update, delete) -- Combine DB-stored and code-defined models -- Handle model versioning -- Model field validation - -**Key Entities:** `CmsModel`, `CmsModelField` - -#### 2. **Content Model Groups Domain** -Organization and categorization of content models. - -**Responsibilities:** -- Manage model groups -- Group membership -- Group access control -- Combine DB-stored and code-defined groups - -**Key Entities:** `CmsGroup` - -#### 3. **Content Entries Domain** -The largest domain - managing actual content data. - -**Sub-domains:** -- **Entry Lifecycle** - Create, update, validate entries -- **Entry Publishing** - Publish, unpublish, republish workflows -- **Entry Deletion** - Soft delete (bin), hard delete, restore -- **Entry Revisions** - Revision management and history -- **Entry Retrieval** - List, get, search, filter entries -- **Entry Location** - Move entries between folders/locations - -**Key Entities:** `CmsEntry`, `CmsEntryMeta`, `CmsStorageEntry` - ---- - -## Migration Strategy - -### Phase 1: Foundation (Week 1-2) - -#### 1.1 Create Domain and Feature Structure - -``` -packages/api-headless-cms/src/ -├── domains/ -│ ├── contentModels/ -│ │ ├── CmsModel.ts # Domain entity/model -│ │ ├── CmsModelField.ts # Domain entity -│ │ ├── ModelValidator.ts # Domain service -│ │ ├── abstractions.ts # Domain abstractions -│ │ ├── errors.ts # Domain errors -│ │ └── types.ts # Domain types -│ │ -│ ├── contentModelGroups/ -│ │ ├── CmsGroup.ts # Domain entity -│ │ ├── abstractions.ts # Domain abstractions -│ │ ├── errors.ts # Domain errors -│ │ └── types.ts # Domain types -│ │ -│ └── contentEntries/ -│ ├── CmsEntry.ts # Domain entity -│ ├── CmsEntryMeta.ts # Domain value object -│ ├── EntryValidator.ts # Domain service -│ ├── EntryTransformer.ts # Domain service -│ ├── abstractions.ts # Domain abstractions -│ ├── errors.ts # Domain errors -│ └── types.ts # Domain types -│ -├── features/ # Application layer (use cases) -│ ├── contentModels/ -│ │ ├── CreateModel/ -│ │ │ ├── abstractions.ts -│ │ │ ├── CreateModelUseCase.ts -│ │ │ ├── events.ts -│ │ │ └── feature.ts -│ │ ├── UpdateModel/ -│ │ ├── DeleteModel/ -│ │ ├── GetModel/ -│ │ ├── ListModels/ -│ │ └── shared/ -│ │ ├── abstractions.ts # ModelsRepository -│ │ ├── ModelsRepository.ts # Infrastructure (DB + plugins) -│ │ └── PluginModelsProvider.ts -│ │ -│ ├── contentModelGroups/ -│ │ ├── CreateGroup/ -│ │ ├── UpdateGroup/ -│ │ ├── DeleteGroup/ -│ │ ├── GetGroup/ -│ │ ├── ListGroups/ -│ │ └── shared/ -│ │ ├── abstractions.ts # GroupsRepository -│ │ ├── GroupsRepository.ts # Infrastructure (DB + plugins) -│ │ └── PluginGroupsProvider.ts -│ │ -│ └── contentEntries/ -│ ├── CreateEntry/ -│ │ ├── abstractions.ts -│ │ ├── CreateEntryUseCase.ts -│ │ ├── decorators/ -│ │ │ ├── CreateEntrySecureDecorator.ts -│ │ │ └── CreateEntryValidationDecorator.ts -│ │ ├── events.ts -│ │ └── feature.ts -│ ├── UpdateEntry/ -│ ├── DeleteEntry/ -│ ├── PublishEntry/ -│ ├── UnpublishEntry/ -│ ├── RepublishEntry/ -│ ├── GetEntry/ -│ ├── ListEntries/ -│ ├── MoveEntry/ -│ ├── RestoreEntry/ -│ ├── CreateRevision/ -│ ├── GetRevisions/ -│ ├── DeleteRevision/ -│ └── shared/ -│ ├── abstractions.ts # EntriesRepository -│ └── EntriesRepository.ts # Infrastructure -│ -├── legacy/ # Backward compatibility layer -│ ├── crud/ # Keep original files temporarily -│ │ ├── contentModel.crud.ts -│ │ ├── contentModelGroup.crud.ts -│ │ └── contentEntry.crud.ts -│ └── adapters/ # Adapters from legacy to new -│ └── LegacyContextAdapter.ts -│ -└── types/ # Keep for now, gradually migrate - ├── types.ts - ├── context.ts - └── plugins.ts -``` - -**Key Architecture Layers:** - -| Layer | Location | Responsibility | Examples | -|-------|----------|---------------|----------| -| **Domain** | `src/domains/` | Business entities, value objects, domain services, domain logic | `CmsModel`, `CmsEntry`, `ModelValidator` | -| **Application** | `src/features/` | Use cases, repositories, application services, orchestration | `CreateModelUseCase`, `ModelsRepository` | -| **Infrastructure** | `src/features/*/shared/` | External concerns, storage, plugins | Repository implementations | -| **Legacy** | `src/legacy/` | Backward compatibility, adapters | `LegacyContextAdapter` | - -#### 1.2 Create Domain Layer - -**Example: Domain Entity** - -```typescript -// domains/contentModels/CmsModel.ts -import type { CmsModelField } from "./CmsModelField.js"; - -export interface CmsModel { - modelId: string; - name: string; - singularApiName: string; - pluralApiName: string; - fields: CmsModelField[]; - layout?: string[][]; - group: string; - description?: string; - tenant: string; - locale: string; - createdOn: string; - savedOn: string; - createdBy: Record; -} -``` - -**Example: Domain Service** - -```typescript -// domains/contentModels/ModelValidator.ts -import type { CmsModel } from "./CmsModel.js"; - -export class ModelValidator { - validate(model: CmsModel): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!model.modelId) { - errors.push("modelId is required"); - } - - if (!model.name) { - errors.push("name is required"); - } - - // Domain validation logic... - - return { valid: errors.length === 0, errors }; - } -} -``` - -#### 1.3 Create Feature Abstractions - -**Example: Repository Abstraction (Application Layer)** - -```typescript -// features/contentModel/shared/abstractions.ts -import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; -import type { ModelNotFoundError, ModelStorageError } from "~/domains/contentModels/errors.js"; - -export interface IModelsRepositoryErrors { - base: ModelNotFoundError | ModelStorageError; -} - -type RepositoryError = IModelsRepositoryErrors[keyof IModelsRepositoryErrors]; - -/** - * ModelsRepository follows CQS (Command-Query Separation): - * - Queries (get, list): Return data wrapped in Result - * - Commands (create, update, delete): Return Result - */ -export interface IModelsRepository { - // Queries - return data - get(modelId: string): Promise>; - list(): Promise>; - - // Commands - return void (side effects only) - create(model: CmsModel): Promise>; - update(modelId: string, data: Partial): Promise>; - delete(modelId: string): Promise>; -} - -export const ModelsRepository = createAbstraction("ModelsRepository"); - -export namespace ModelsRepository { - export type Interface = IModelsRepository; - export type Error = RepositoryError; -} -``` - -#### 1.4 Implement Repository Pattern for Dual Sources - -**Key Innovation: Unified Repository for DB + Code Models** - -Following the pattern from `api-core` (GroupProvider/TeamProvider), create repositories that transparently handle both database-stored and plugin-defined models. - -```typescript -// features/contentModel/shared/ModelsRepository.ts -import { createImplementation } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import { ModelsRepository as RepositoryAbstraction } from "./abstractions.js"; -import { ModelNotFoundError, ModelStorageError } from "~/domains/contentModels/errors.js"; -import type { HeadlessCmsStorageOperations } from "~/types/index.js"; -import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; - -class ModelsRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private storageOperations: HeadlessCmsStorageOperations, - private pluginModels: CmsModel[], // Injected from plugin registry - private accessControl: AccessControl.Interface - ) {} - - async get(modelId: string): Promise> { - // 1. Check plugin models first (code-defined, cached) - const pluginModel = this.pluginModels.find(m => m.modelId === modelId); - if (pluginModel) { - const canAccess = await this.accessControl.canAccessModel({ model: pluginModel }); - if (!canAccess) { - return Result.fail(new ModelNotFoundError(modelId)); - } - return Result.ok(pluginModel); - } - - // 2. Query database models - try { - const dbModel = await this.storageOperations.models.get({ modelId }); - if (!dbModel) { - return Result.fail(new ModelNotFoundError(modelId)); - } - - const canAccess = await this.accessControl.canAccessModel({ model: dbModel }); - if (!canAccess) { - return Result.fail(new ModelNotFoundError(modelId)); - } - - return Result.ok(dbModel); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async list(): Promise> { - try { - // 1. Get DB models - const dbModels = await this.storageOperations.models.list(); - - // 2. Combine with plugin models - const allModels = [...this.pluginModels, ...dbModels]; - - // 3. Apply access control - const accessibleModels = await Promise.all( - allModels.map(async model => { - const canAccess = await this.accessControl.canAccessModel({ model }); - return canAccess ? model : null; - }) - ); - - return Result.ok(accessibleModels.filter(Boolean) as CmsModel[]); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async create(model: CmsModel): Promise> { - // Only DB models can be created (plugin models are code-defined) - try { - await this.storageOperations.models.create(model); - return Result.ok(); // ✅ CQS: Commands return void - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async update( - modelId: string, - data: Partial - ): Promise> { - // Cannot update plugin models - const pluginModel = this.pluginModels.find(m => m.modelId === modelId); - if (pluginModel) { - return Result.fail( - new ModelStorageError( - new Error("Cannot update code-defined models") - ) - ); - } - - try { - await this.storageOperations.models.update(modelId, data); - return Result.ok(); // ✅ CQS: Commands return void - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } - - async delete(modelId: string): Promise> { - // Cannot delete plugin models - const pluginModel = this.pluginModels.find(m => m.modelId === modelId); - if (pluginModel) { - return Result.fail( - new ModelStorageError( - new Error("Cannot delete code-defined models") - ) - ); - } - - try { - await this.storageOperations.models.delete(modelId); - return Result.ok(); - } catch (error) { - return Result.fail(new ModelStorageError(error as Error)); - } - } -} - -export const ModelsRepositoryImpl = createImplementation({ - abstraction: RepositoryAbstraction, - implementation: ModelsRepositoryImpl, - dependencies: [ - HeadlessCmsStorageOperations, - PluginModelsProvider, // New: provides plugin models - AccessControl - ] -}); -``` - ---- - -### Phase 2: Use Case Implementation (Week 3-4) - -#### 2.0 CQS (Command-Query Separation) Principle - -**All repositories and use cases MUST follow CQS principle:** - -| Type | Returns | Side Effects | Examples | -|------|---------|--------------|----------| -| **Query** | `Result` | No side effects (read-only) | `get()`, `list()`, `find()` | -| **Command** | `Result` | Has side effects (write) | `create()`, `update()`, `delete()` | - -**Repository Pattern with CQS:** - -```typescript -interface IModelsRepository { - // ✅ Queries - return data - get(id: string): Promise>; - list(): Promise>; - - // ✅ Commands - return void - create(model: CmsModel): Promise>; - update(id: string, data: Partial): Promise>; - delete(id: string): Promise>; -} -``` - -**Use Case Pattern with CQS:** - -```typescript -// ✅ Command Use Case -interface ICreateModel { - execute(input: CreateModelInput): Promise>; -} - -// ✅ Query Use Case -interface IGetModel { - execute(input: GetModelInput): Promise>; -} - -// ✅ Query Use Case (list) -interface IListModels { - execute(input: ListModelsInput): Promise>; -} -``` - -**Benefits:** -1. ✅ Clear separation between reads and writes -2. ✅ Easier to reason about side effects -3. ✅ Better caching strategies (queries can be cached) -4. ✅ Simpler testing (queries are pure functions) -5. ✅ Follows api-core patterns - -**Note:** Storage operations (legacy) can remain unchanged. Only repositories and use cases follow CQS. - -**Entries Repository Example with CQS:** - -```typescript -interface IEntriesRepository { - // ✅ Queries - get(model: CmsModel, id: string): Promise>; - getLatestRevision(model: CmsModel, entryId: string): Promise>; - getPublishedRevision(model: CmsModel, entryId: string): Promise>; - list(model: CmsModel, params: ListParams): Promise>; - getRevisions(model: CmsModel, entryId: string): Promise>; - - // ✅ Commands - create(model: CmsModel, entry: CmsEntry): Promise>; - update(model: CmsModel, id: string, data: Partial): Promise>; - delete(model: CmsModel, id: string): Promise>; - publish(model: CmsModel, id: string): Promise>; - unpublish(model: CmsModel, id: string): Promise>; - moveToBin(model: CmsModel, id: string): Promise>; - restoreFromBin(model: CmsModel, id: string): Promise>; -} -``` - ---- - -#### 2.1 Content Models Use Cases - -**Example: CreateModel Use Case** - -```typescript -// features/contentModel/CreateModel/abstractions.ts -import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import { ModelsRepository } from "../shared/abstractions.js"; -import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; - -export interface CreateModelInput { - name: string; - modelId: string; - group: string; - fields: any[]; // Field definitions - layout?: string[][]; - description?: string; -} - -export interface ICreateModelErrors { - validation: ModelValidationError; - alreadyExists: ModelAlreadyExistsError; -} - -type CreateModelError = ICreateModelErrors[keyof ICreateModelErrors] | ModelsRepository.Error; - -/** - * CreateModel follows CQS: - * This is a COMMAND - returns Result - * To get the created model, use GetModel query - */ -export interface ICreateModel { - execute(input: CreateModelInput): Promise>; -} - -export const CreateModel = createAbstraction("CreateModel"); - -export namespace CreateModel { - export type Interface = ICreateModel; - export type Error = CreateModelError; - export type Input = CreateModelInput; -} -``` - -```typescript -// features/contentModel/CreateModel/CreateModelUseCase.ts -import { createImplementation } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import { CreateModel as UseCaseAbstraction } from "./abstractions.js"; -import { ModelsRepository } from "../shared/abstractions.js"; -import { EventPublisher } from "@webiny/api-core"; -import { ModelBeforeCreateEvent, ModelAfterCreateEvent } from "./events.js"; -import { ModelValidationError, ModelAlreadyExistsError } from "~/domains/contentModels/errors.js"; - -class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { - constructor( - private repository: ModelsRepository.Interface, - private eventPublisher: EventPublisher.Interface, - private validator: ModelValidator.Interface - ) {} - - async execute( - input: UseCaseAbstraction.Input - ): Promise> { - // 1. Validate input - const validation = await this.validator.validate(input); - if (validation.isFail()) { - return Result.fail(new ModelValidationError(validation.error.message)); - } - - // 2. Check if model already exists - const existing = await this.repository.get(input.modelId); - if (existing.isOk()) { - return Result.fail(new ModelAlreadyExistsError(input.modelId)); - } - - // 3. Create model object - const model: CmsModel = { - ...input, - tenant: getTenant().id, - locale: getLocale().code, - createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), - createdBy: getIdentity() - }; - - // 4. Publish before event - await this.eventPublisher.publish( - new ModelBeforeCreateEvent({ model, input }) - ); - - // 5. Save to repository (CQS: returns void) - const result = await this.repository.create(model); - if (result.isFail()) { - return Result.fail(result.error); - } - - // 6. Publish after event - await this.eventPublisher.publish( - new ModelAfterCreateEvent({ model, input }) - ); - - // ✅ CQS: Command returns void - // Client can use GetModel query to retrieve created model - return Result.ok(); - } -} - -export const CreateModelUseCase = createImplementation({ - abstraction: UseCaseAbstraction, - implementation: CreateModelUseCaseImpl, - dependencies: [ModelsRepository, EventPublisher, ModelValidator] -}); -``` - -#### 2.2 Content Entries Use Cases - -**Priority Order for Migration:** - -1. ✅ **CreateEntry** - Most fundamental operation -2. ✅ **GetEntry** - Single entry retrieval -3. ✅ **ListEntries** - Bulk retrieval with filtering -4. ✅ **UpdateEntry** - Entry modification -5. ✅ **PublishEntry** - Publishing workflow -6. ✅ **UnpublishEntry** - Unpublishing workflow -7. ✅ **DeleteEntry** - Soft delete to bin -8. ✅ **RestoreEntry** - Restore from bin -9. ✅ **CreateRevision** - Revision branching -10. ✅ **GetRevisions** - Revision history - -**IMPORTANT:** Existing use cases in `crud/contentEntry/useCases/` need **refactoring to new DI architecture**. Keep existing logic but adapt to proper abstractions and feature structure. - -### Current Architecture Issues - -1. **No DI abstractions** - Use cases don't use `createAbstraction` or `createImplementation` -2. **Manual composition** - Decorators manually composed in factory functions -3. **Events as decorators** - Events wrapped as decorators instead of being in use case -4. **Concrete dependencies** - Constructor takes concrete types, not abstractions - -### Migration Strategy: Refactor to New Architecture - -**Current Structure (Example: DeleteEntry):** -``` -crud/contentEntry/useCases/DeleteEntry/ -├── DeleteEntry.ts # Orchestrator -├── DeleteEntryOperation.ts # Base operation -├── DeleteEntryOperationWithEvents.ts # ❌ Events as decorator -├── DeleteEntrySecure.ts # ✅ Real decorator (authorization) -├── TransformEntryDelete.ts # ✅ Real decorator (transform) -└── index.ts # Manual factory -``` - -**Target Structure:** -``` -features/contentEntry/DeleteEntry/ -├── abstractions.ts # DI abstractions -├── DeleteEntryUseCase.ts # Use case with events inside ✅ -├── decorators/ -│ ├── DeleteEntrySecureDecorator.ts # Authorization (from Secure) -│ └── DeleteEntryTransformDecorator.ts # Transform (from Transform) -├── events.ts # Event class definitions -└── feature.ts # DI registration -``` - -### Refactoring Pattern - -**Step 1: Create Abstractions** -```typescript -// features/contentEntry/DeleteEntry/abstractions.ts -import { createAbstraction } from "@webiny/feature/api"; -import type { CmsEntry } from "~/domains/contentEntries/CmsEntry.js"; -import type { CmsModel } from "~/domains/contentModels/CmsModel.js"; - -export interface DeleteEntryInput { - model: CmsModel; - id: string; - options?: { force?: boolean }; -} - -export interface IDeleteEntry { - execute(input: DeleteEntryInput): Promise; -} - -export const DeleteEntry = createAbstraction("DeleteEntry"); - -export namespace DeleteEntry { - export type Interface = IDeleteEntry; - export type Input = DeleteEntryInput; -} -``` - -**Step 2: Refactor Use Case (Merge Operation + Events)** - -```typescript -// features/contentEntry/DeleteEntry/DeleteEntryUseCase.ts -import { createImplementation } from "@webiny/feature/api"; -import { DeleteEntry as UseCaseAbstraction } from "./abstractions.js"; -import { EntriesRepository } from "../shared/abstractions.js"; -import { EventPublisher } from "@webiny/api-core"; -import { EntryBeforeDeleteEvent, EntryAfterDeleteEvent } from "./events.js"; - -class DeleteEntryUseCaseImpl implements UseCaseAbstraction.Interface { - constructor( - private repository: EntriesRepository.Interface, - private eventPublisher: EventPublisher.Interface // ✅ Events in use case - ) {} - - async execute(input: UseCaseAbstraction.Input): Promise { - const { model, id, options } = input; - - // Get entry - const entry = await this.repository.getLatestRevision(model, id); - if (!entry && !options?.force) { - throw new NotFoundError(`Entry "${id}" was not found!`); - } - - // ✅ Publish BEFORE event (part of use case logic) - await this.eventPublisher.publish( - new EntryBeforeDeleteEvent({ model, entry, input }) - ); - - // Execute deletion - await this.repository.delete(model, { entry }); - - // ✅ Publish AFTER event (part of use case logic) - await this.eventPublisher.publish( - new EntryAfterDeleteEvent({ model, entry, input }) - ); - } -} - -export const DeleteEntryUseCaseImpl = createImplementation({ - abstraction: UseCaseAbstraction, - implementation: DeleteEntryUseCaseImpl, - dependencies: [EntriesRepository, EventPublisher] // ✅ EventPublisher injected -}); -``` - -**Step 3: Refactor ONLY Real Decorators (Not Events)** - -```typescript -// features/contentEntry/DeleteEntry/decorators/DeleteEntrySecureDecorator.ts -import { createDecorator } from "@webiny/feature/api"; -import { DeleteEntry } from "../abstractions.js"; -import { AccessControl } from "~/crud/AccessControl/abstractions.js"; - -// ✅ Refactor DeleteEntrySecure - this IS a real decorator (cross-cutting concern) -class DeleteEntrySecureDecoratorImpl implements DeleteEntry.Interface { - constructor( - private accessControl: AccessControl.Interface, - private decoratee: DeleteEntry.Interface - ) {} - - async execute(input: DeleteEntry.Input): Promise { - // Authorization check - await this.accessControl.ensureCanDelete({ model: input.model }); - - // Delegate to use case - return this.decoratee.execute(input); - } -} - -export const DeleteEntrySecureDecorator = createDecorator({ - abstraction: DeleteEntry, - decorator: DeleteEntrySecureDecoratorImpl, - dependencies: [AccessControl] -}); -``` - -**Step 4: Feature Registration** -```typescript -// features/contentEntry/DeleteEntry/feature.ts -import { createFeature } from "@webiny/feature"; -import { DeleteEntryUseCaseImpl } from "./DeleteEntryUseCase.js"; -import { DeleteEntrySecureDecorator } from "./decorators/DeleteEntrySecureDecorator.js"; -import { DeleteEntryTransformDecorator } from "./decorators/DeleteEntryTransformDecorator.js"; - -export const DeleteEntryFeature = createFeature({ - name: "DeleteEntry", - register(container) { - // Register use case (with events inside) - container.register(DeleteEntryUseCaseImpl); - - // Register ONLY real decorators (not events) - container.registerDecorator(DeleteEntrySecureDecorator); - container.registerDecorator(DeleteEntryTransformDecorator); - } -}); -``` - -### Key Refactoring Rules - -1. ✅ **Events IN use case** - Not as decorators, directly in use case logic -2. ✅ **Keep real decorators** - Authorization, Transform, Validation are decorators -3. ✅ **Merge Operation + WithEvents** - Combine into single use case -4. ✅ **EventPublisher injected** - Use case depends on EventPublisher -5. ✅ **Keep existing logic** - Just restructure, don't change behavior -6. ❌ **No event decorators** - Remove `*WithEvents` pattern entirely - ---- - -### Phase 3: Event System Unification (Week 5) - -#### 3.1 Migrate from Pub/Sub Topics to DI Events - -**Current State:** -```typescript -// In CRUD files - pub/sub pattern -const onEntryBeforeCreate = createTopic("cms.onEntryBeforeCreate"); -await onEntryBeforeCreate.publish({ entry, model }); -``` - -**Target State:** -```typescript -// In use cases - DI-based events -await this.eventPublisher.publish( - new EntryBeforeCreateEvent({ entry, model }) -); -``` - -#### 3.2 Event Definitions - -```typescript -// domains/contentEntries/features/CreateEntry/events.ts -import { createAbstraction } from "@webiny/feature/api"; -import { DomainEvent } from "@webiny/api-core"; -import type { IEventHandler } from "@webiny/api-core"; -import type { CmsEntry, CmsModel, CreateEntryInput } from "~/types/index.js"; - -export interface EntryBeforeCreatePayload { - entry: CmsEntry; - model: CmsModel; - input: CreateEntryInput; -} - -export class EntryBeforeCreateEvent extends DomainEvent { - eventType = "entry.beforeCreate" as const; - - getHandlerAbstraction() { - return EntryBeforeCreateHandler; - } -} - -export const EntryBeforeCreateHandler = createAbstraction< - IEventHandler ->("EntryBeforeCreateHandler"); - -export namespace EntryBeforeCreateHandler { - export type Interface = IEventHandler; - export type Event = EntryBeforeCreateEvent; -} - -// After event -export interface EntryAfterCreatePayload { - entry: CmsEntry; - model: CmsModel; - input: CreateEntryInput; -} - -export class EntryAfterCreateEvent extends DomainEvent { - eventType = "entry.afterCreate" as const; - - getHandlerAbstraction() { - return EntryAfterCreateHandler; - } -} - -export const EntryAfterCreateHandler = createAbstraction< - IEventHandler ->("EntryAfterCreateHandler"); - -export namespace EntryAfterCreateHandler { - export type Interface = IEventHandler; - export type Event = EntryAfterCreateEvent; -} -``` - -#### 3.3 Migration Strategy for Events - -1. **Keep both systems temporarily** - Allow gradual migration -2. **Create bridge adapters** - Convert pub/sub to event handlers -3. **Deprecate pub/sub topics** - Mark as deprecated, log warnings -4. **Remove in next major version** - Clean removal path - ---- - -### Phase 4: Feature Registration (Week 6) - -#### 4.1 Feature Definitions - -```typescript -// domains/contentModels/features/CreateModel/feature.ts -import { createFeature } from "@webiny/feature"; -import { CreateModelUseCaseImpl } from "./CreateModelUseCase.js"; -import { CreateModelValidationDecorator } from "./decorators/ValidationDecorator.js"; -import { CreateModelAuthorizationDecorator } from "./decorators/AuthorizationDecorator.js"; - -export const CreateModelFeature = createFeature({ - name: "CreateModel", - register(container) { - // Register use case - container.register(CreateModelUseCaseImpl); - - // Register decorators - container.registerDecorator(CreateModelValidationDecorator); - container.registerDecorator(CreateModelAuthorizationDecorator); - } -}); -``` - -#### 4.2 Domain-Level Features - -```typescript -// domains/contentModels/feature.ts -import { createFeature } from "@webiny/feature"; -import { CreateModelFeature } from "./features/CreateModel/feature.js"; -import { UpdateModelFeature } from "./features/UpdateModel/feature.js"; -import { DeleteModelFeature } from "./features/DeleteModel/feature.js"; -import { GetModelFeature } from "./features/GetModel/feature.js"; -import { ListModelsFeature } from "./features/ListModels/feature.js"; -import { ModelsRepositoryImpl } from "./shared/ModelsRepository.js"; -import { ModelValidatorImpl } from "./shared/ModelValidator.js"; - -export const ContentModelsFeature = createFeature({ - name: "ContentModels", - register(container) { - // Register shared components - container.register(ModelsRepositoryImpl).inSingletonScope(); - container.register(ModelValidatorImpl).inSingletonScope(); - - // Register all model features - CreateModelFeature.register(container); - UpdateModelFeature.register(container); - DeleteModelFeature.register(container); - GetModelFeature.register(container); - ListModelsFeature.register(container); - } -}); -``` - ---- - -### Phase 5: Backward Compatibility Layer (Week 7) - -#### 5.1 Legacy Context Adapter - -Maintain backward compatibility with existing code that uses the old context API. - -```typescript -// legacy/adapters/LegacyContextAdapter.ts -import type { CmsModelContext, CmsGroupContext, CmsEntryContext } from "~/types/index.js"; -import { Container } from "@webiny/di"; -import { CreateModel } from "~/domains/contentModels/features/CreateModel/abstractions.js"; -import { GetModel } from "~/domains/contentModels/features/GetModel/abstractions.js"; -// ... other imports - -export class LegacyModelContextAdapter implements CmsModelContext { - constructor(private container: Container) {} - - async createModel(data: any) { - const useCase = this.container.resolve(CreateModel); - const result = await useCase.execute(data); - - if (result.isFail()) { - throw new Error(result.error.message); - } - - return result.value; - } - - async getModel(modelId: string) { - const useCase = this.container.resolve(GetModel); - const result = await useCase.execute({ modelId }); - - if (result.isFail()) { - throw new NotFoundError(result.error.message); - } - - return result.value; - } - - // ... implement all other methods -} -``` - -#### 5.2 Dual Registration - -```typescript -// context.ts (main context creation) -export const createHeadlessCmsContext = () => { - return new ContextPlugin(async context => { - const container = new Container(); - - // Register all features - ContentModelsFeature.register(container); - ContentModelGroupsFeature.register(container); - ContentEntriesFeature.register(container); - - // LEGACY: Backward compatible CRUD API - context.cms.models = new LegacyModelContextAdapter(container); - context.cms.groups = new LegacyGroupContextAdapter(container); - context.cms.entries = new LegacyEntryContextAdapter(container); - }); -}; -``` - ---- - -## Implementation Checklist - -### Phase 1: Foundation ✅ -- [ ] Create domain folder structure -- [ ] Define shared abstractions for all domains -- [ ] Implement ModelsRepository (DB + plugin models) -- [ ] Implement GroupsRepository (DB + plugin groups) -- [ ] Implement EntriesRepository -- [ ] Create domain-specific error classes -- [ ] Create PluginModelsProvider abstraction -- [ ] Create PluginGroupsProvider abstraction - -### Phase 2: Use Cases ✅ -- [ ] **Content Models:** - - [ ] CreateModel use case (with domain events and event handler abstractions) - - [ ] UpdateModel use case (with domain events and event handler abstractions) - - [ ] DeleteModel use case (with domain events and event handler abstractions) - - [ ] GetModel use case - - [ ] ListModels use case -- [ ] **Content Model Groups:** - - [ ] CreateGroup use case (with domain events and event handler abstractions) - - [ ] UpdateGroup use case (with domain events and event handler abstractions) - - [ ] DeleteGroup use case (with domain events and event handler abstractions) - - [ ] GetGroup use case - - [ ] ListGroups use case -- [ ] **Content Entries:** - - [ ] CreateEntry use case (with domain events and event handler abstractions) - - [ ] UpdateEntry use case (with domain events and event handler abstractions) - - [ ] DeleteEntry use case (move to bin) (with domain events and event handler abstractions) - - [ ] RestoreEntry use case (with domain events and event handler abstractions) - - [ ] GetEntry use case - - [ ] ListEntries use case - - [ ] PublishEntry use case (with domain events and event handler abstractions) - - [ ] UnpublishEntry use case (with domain events and event handler abstractions) - - [ ] RepublishEntry use case (with domain events and event handler abstractions) - - [ ] CreateRevision use case (with domain events and event handler abstractions) - - [ ] GetRevisions use case - - [ ] DeleteRevision use case (with domain events and event handler abstractions) - - [ ] MoveEntry use case (with domain events and event handler abstractions) - -### Phase 3: Events ✅ -- [ ] Define all domain events -- [ ] Create event handler abstractions -- [ ] Migrate from pub/sub topics to EventPublisher -- [ ] Create bridge adapters for backward compatibility -- [ ] Update all use cases to publish events - -### Phase 4: Features ✅ -- [ ] Create feature definitions for all use cases -- [ ] Create domain-level feature aggregators -- [ ] Register features in DI container -- [ ] Add decorators for cross-cutting concerns - -### Phase 5: Compatibility ✅ -- [ ] Create LegacyContextAdapter -- [ ] Implement all legacy context methods -- [ ] Add deprecation warnings -- [ ] Write migration guide for consumers -- [ ] Update documentation - -### Phase 6: Testing & Validation ✅ -- [ ] Write unit tests for all use cases -- [ ] Write integration tests for repositories -- [ ] Test backward compatibility -- [ ] Performance testing -- [ ] Update existing tests - ---- - -## Repository Pattern Details - -### Key Innovation: Provider Pattern for Plugin Models - -Following `api-core` pattern: - -```typescript -// domains/contentModels/shared/abstractions.ts -export interface IPluginModelsProvider { - getModels(): Promise; -} - -export const PluginModelsProvider = createAbstraction( - "PluginModelsProvider" -); - -export namespace PluginModelsProvider { - export type Interface = IPluginModelsProvider; -} -``` - -```typescript -// domains/contentModels/shared/PluginModelsProvider.ts -class PluginModelsProviderImpl implements PluginModelsProvider.Interface { - constructor( - private pluginRegistry: PluginRegistry, - private tenantContext: TenantContext.Interface, - private localeContext: LocaleContext.Interface - ) {} - - async getModels(): Promise { - const tenant = this.tenantContext.getTenant(); - const locale = this.localeContext.getLocale(); - - const plugins = this.pluginRegistry.byType( - CmsModelPlugin.type - ); - - return plugins - .filter(plugin => { - const model = plugin.contentModel; - // Filter by tenant/locale if specified - if (model.tenant && model.tenant !== tenant.id) return false; - if (model.locale && model.locale !== locale.code) return false; - return true; - }) - .map(plugin => ({ - ...plugin.contentModel, - tenant: tenant.id, - locale: locale.code - })); - } -} -``` - -**Benefits:** -1. ✅ Single repository interface for consumers -2. ✅ Transparent handling of dual sources -3. ✅ Proper access control applied to both -4. ✅ Caching handled at repository level -5. ✅ Clear separation between code and DB models - ---- - -## Domain Event Examples - -### Content Model Events - -```typescript -// Model lifecycle -model.beforeCreate -model.afterCreate -model.beforeUpdate -model.afterUpdate -model.beforeDelete -model.afterDelete -model.createError -model.updateError -model.deleteError -``` - -### Content Entry Events - -```typescript -// Entry lifecycle -entry.beforeCreate -entry.afterCreate -entry.beforeUpdate -entry.afterUpdate -entry.beforeDelete -entry.afterDelete - -// Publishing -entry.beforePublish -entry.afterPublish -entry.beforeUnpublish -entry.afterUnpublish -entry.beforeRepublish -entry.afterRepublish - -// Revisions -entry.revision.beforeCreate -entry.revision.afterCreate -entry.revision.beforeDelete -entry.revision.afterDelete - -// Location -entry.beforeMove -entry.afterMove - -// Soft delete -entry.beforeMoveToBin -entry.afterMoveToBin -entry.beforeRestoreFromBin -entry.afterRestoreFromBin - -// Errors -entry.createError -entry.updateError -entry.deleteError -entry.publishError -entry.unpublishError -entry.republishError -``` - ---- - -## Migration Risks & Mitigations - -### Risk 1: Breaking Changes -**Mitigation:** Maintain backward compatibility layer for entire migration period. Deprecate gradually. - -### Risk 2: Performance Regression -**Mitigation:** -- Keep existing caching strategies -- Add performance benchmarks -- Monitor repository query patterns - -### Risk 3: Complex Entry Operations -**Mitigation:** -- Migrate simpler operations first (models, groups) -- Use existing use case structure as foundation -- Extensive testing for entry workflows - -### Risk 4: Plugin Compatibility -**Mitigation:** -- Keep plugin interfaces unchanged (out of scope) -- Provider pattern isolates plugin handling -- Test with real plugin implementations - ---- - -## Success Criteria - -1. ✅ All CRUD operations available as use cases -2. ✅ Repository pattern successfully unifies DB + plugin models -3. ✅ Event system migrated to DI-based handlers -4. ✅ Full backward compatibility maintained -5. ✅ Test coverage equivalent or better than current -6. ✅ Performance equivalent or better than current -7. ✅ Clear domain boundaries established -8. ✅ Documentation updated - ---- - -## Timeline - -**Total Estimated Time: 7-8 weeks** - -| Phase | Duration | Deliverables | -|-------|----------|--------------| -| Phase 1 | 2 weeks | Domain structure, repositories, abstractions | -| Phase 2 | 2 weeks | All use cases implemented | -| Phase 3 | 1 week | Event system migrated | -| Phase 4 | 1 week | Features registered | -| Phase 5 | 1 week | Backward compatibility | -| Phase 6 | 1-2 weeks | Testing, validation, documentation | - ---- - -## Next Steps - -1. **Review & Approve** this migration plan -2. **Create tracking issues** for each phase -3. **Set up feature branches** for parallel development -4. **Begin Phase 1** with domain structure and repositories -5. **Establish testing strategy** before use case implementation - ---- - -## Questions for Clarification - -1. Should we migrate all entry use cases, or prioritize specific ones? -2. Is there a specific release timeline we need to align with? -3. Should we keep legacy crud files indefinitely or plan removal? -4. Are there specific plugin implementations we need to test against? -5. Should we introduce any new capabilities during migration (or pure refactor)? diff --git a/packages/api-record-locking/MIGRATION_PLAN.md b/packages/api-record-locking/MIGRATION_PLAN.md deleted file mode 100644 index 7f049447298..00000000000 --- a/packages/api-record-locking/MIGRATION_PLAN.md +++ /dev/null @@ -1,530 +0,0 @@ -# Migration Plan: api-record-locking → Feature-Based Architecture - -## Current Architecture Issues - -1. **`getManager()` pattern**: Uses async function that returns entry manager - should inject use cases directly -2. **No abstractions**: Use cases created imperatively without DI abstractions -3. **No events**: Uses pubsub topics instead of EventPublisher pattern -4. **Direct CMS dependencies**: Directly calls `context.cms.getModel()` and `context.cms.getEntryManager()` -5. **Mixed concerns**: CRUD factory mixes setup logic with business logic - -## Migration Strategy - -### Phase 1: Create Feature Structure - -``` -packages/api-record-locking/src/features/ -├── shared/ -│ ├── abstractions.ts # Shared types, domain model interfaces -│ ├── errors.ts # Domain errors -│ └── LockRecord.ts # Domain model -├── LockEntry/ -│ ├── abstractions.ts -│ ├── events.ts -│ ├── LockEntryUseCase.ts -│ ├── LockEntryRepository.ts # Repository for this use case only -│ ├── feature.ts -│ └── types.ts -├── UnlockEntry/ -│ ├── abstractions.ts -│ ├── events.ts -│ ├── UnlockEntryUseCase.ts -│ ├── UnlockEntryRepository.ts # Repository for this use case only -│ ├── feature.ts -│ └── types.ts -├── GetLockRecord/ -│ ├── abstractions.ts -│ ├── GetLockRecordUseCase.ts -│ ├── GetLockRecordRepository.ts # Repository for this use case only -│ └── feature.ts -├── ListLockRecords/ -│ ├── abstractions.ts -│ ├── ListLockRecordsUseCase.ts -│ ├── ListLockRecordsRepository.ts # Repository for this use case only -│ └── feature.ts -├── UpdateEntryLock/ -│ ├── abstractions.ts -│ ├── UpdateEntryLockUseCase.ts -│ ├── UpdateEntryLockRepository.ts # Repository for this use case only -│ └── feature.ts -└── RecordLockingManagement/ - └── feature.ts # Composite feature -``` - -### Phase 2: Replace `getManager()` with Proper Dependencies - -**Current Pattern (Bad):** - -```typescript -getManager(): Promise -``` - -**New Pattern (Good):** - -```typescript -// Inject proper use cases from cms package -constructor( - private getEntryById: GetEntryByIdUseCase.Interface, - private createEntry: CreateEntryUseCase.Interface, - private deleteEntry: DeleteEntryUseCase.Interface, - private getModel: GetModelUseCase.Interface -) -``` - -### Phase 3: Convert PubSub Topics to EventPublisher - -**Current (Bad):** - -```typescript -const onEntryBeforeLock = createTopic(); -const onEntryAfterLock = createTopic(); -await onEntryBeforeLock.publish(params); -``` - -**New (Good):** - -```typescript -// events.ts -export class EntryBeforeLockEvent extends DomainEvent { - eventType = "RecordLocking/Entry/BeforeLock" as const; - getHandlerAbstraction() { return EntryBeforeLockHandler; } -} - -export const EntryBeforeLockHandler = - createAbstraction>("EntryBeforeLockHandler"); - -export namespace EntryBeforeLockHandler { - export type Interface = IEventHandler; - export type Event = EntryBeforeLockEvent; -} - -// UseCase -await this.eventPublisher.publish(new EntryBeforeLockEvent({ id, type })); -``` - -### Phase 4: Use Existing Abstractions for Dependencies - -**Import IdentityContext (replaces SecurityGateway):** - -```typescript -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; - -// In use case constructor -constructor( - private identityContext: IdentityContext.Interface, - // ... other dependencies -) {} - -// Usage in use case -const identity = await this.identityContext.getIdentity(); -``` - -**Import WebsocketsContext:** - -```typescript -import { WebsocketsContext } from "@webiny/api-websockets/features/WebsocketsContext"; - -// In use case constructor -constructor( - private websocketsContext: WebsocketsContext.Interface, - // ... other dependencies -) {} - -// Usage in use case -await this.websocketsContext.send(identity, data); -``` - -**Note:** These abstractions are already registered in their respective packages, so no manual registration needed. - -### Phase 5: Domain Errors - -**Create domain-specific errors:** - -```typescript -// shared/errors.ts -import { BaseError } from "@webiny/feature/api"; - -export class EntryAlreadyLockedError extends BaseError { - override readonly code = "RecordLocking/EntryAlreadyLockedError" as const; - - constructor(data: { id: string; type: string }) { - super({ - message: "Entry is already locked for editing.", - data - }); - } -} - -export class LockRecordNotFoundError extends BaseError { - override readonly code = "RecordLocking/LockRecordNotFoundError" as const; - - constructor(data: { id: string }) { - super({ - message: "Lock Record not found.", - data - }); - } -} - -export class LockRecordPersistenceError extends BaseError { - override readonly code = "RecordLocking/LockRecordPersistenceError" as const; - - constructor(error: Error) { - super({ - message: error.message, - data: {} - }); - } -} - -export class NotSameIdentityError extends BaseError { - override readonly code = "RecordLocking/NotSameIdentityError" as const; - - constructor(data: { currentId: string; targetId: string }) { - super({ - message: "Identity mismatch - cannot perform action.", - data - }); - } -} - -export class UnlockEntryError extends BaseError { - override readonly code = "RecordLocking/UnlockEntryError" as const; - - constructor(error: Error) { - super({ - message: `Could not unlock entry: ${error.message}`, - data: {} - }); - } -} - -export class LockEntryError extends BaseError { - override readonly code = "RecordLocking/LockEntryError" as const; - - constructor(error: Error) { - super({ - message: `Could not lock entry: ${error.message}`, - data: {} - }); - } -} -``` - -### Phase 6: Key Use Cases to Migrate - -#### 1. LockEntry - Lock an entry for editing - -**Dependencies:** -- `LockEntryRepository` - Internal repository (injected into use case) -- `IsEntryLockedUseCase` - Internal use case -- `EventPublisher` - From `@webiny/api-core/features/EventPublisher` -- `IdentityContext` - From `@webiny/api-core/features/IdentityContext` - -**Repository Dependencies (LockEntryRepository):** -- `GetEntryByIdUseCase` - From `@webiny/api-headless-cms/features/contentEntry/GetEntryById` -- `CreateEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/CreateEntry` - -**Events:** -- `EntryBeforeLockEvent` -- `EntryAfterLockEvent` -- `EntryLockErrorEvent` - -**Errors:** -- `EntryAlreadyLockedError` -- `LockEntryError` - -#### 2. UnlockEntry - Unlock an entry - -**Dependencies:** -- `UnlockEntryRepository` - Internal repository (injected into use case) -- `GetLockRecordUseCase` - Internal use case -- `KickOutCurrentUserUseCase` - Internal use case -- `EventPublisher` - From `@webiny/api-core/features/EventPublisher` -- `IdentityContext` - From `@webiny/api-core/features/IdentityContext` -- `WebsocketsContext` - From `@webiny/api-websockets/features/WebsocketsContext` - -**Repository Dependencies (UnlockEntryRepository):** -- `DeleteEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/DeleteEntry` - -**Events:** -- `EntryBeforeUnlockEvent` -- `EntryAfterUnlockEvent` -- `EntryUnlockErrorEvent` - -**Errors:** -- `LockRecordNotFoundError` -- `NotSameIdentityError` -- `UnlockEntryError` - -#### 3. GetLockRecord - Get lock record for entry - -**Dependencies:** -- `GetLockRecordRepository` - Internal repository (injected into use case) - -**Repository Dependencies (GetLockRecordRepository):** -- `GetEntryByIdUseCase` - From `@webiny/api-headless-cms/features/contentEntry/GetEntryById` - -**Errors:** -- `LockRecordNotFoundError` - -#### 4. ListLockRecords - List all lock records - -**Dependencies:** -- `ListLockRecordsRepository` - Internal repository (injected into use case) - -**Repository Dependencies (ListLockRecordsRepository):** -- `ListEntriesUseCase` - From `@webiny/api-headless-cms/features/contentEntry/ListEntries` - -**Errors:** -- `LockRecordPersistenceError` - -#### 5. UpdateEntryLock - Update lock timestamp - -**Dependencies:** -- `UpdateEntryLockRepository` - Internal repository (injected into use case) -- `GetLockRecordUseCase` - Internal use case - -**Repository Dependencies (UpdateEntryLockRepository):** -- `UpdateEntryUseCase` - From `@webiny/api-headless-cms/features/contentEntry/UpdateEntry` - -**Errors:** -- `LockRecordNotFoundError` -- `LockRecordPersistenceError` - -#### 6. IsEntryLocked - Check if entry is locked - -**Dependencies:** -- `GetLockRecordUseCase` - Internal use case - -**Returns:** `boolean` - -### Phase 7: Repository Pattern - One Repository Per Use Case - -**Example: LockEntryRepository** - -```typescript -// features/LockEntry/LockEntryRepository.ts -import { createAbstraction } from "@webiny/feature/api"; -import { Result } from "@webiny/feature/api"; -import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; -import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; -import { LockEntryRepository as RepositoryAbstraction } from "./abstractions.js"; -import type { LockRecord } from "../shared/LockRecord.js"; - -class LockEntryRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private createEntry: CreateEntryUseCase.Interface - ) {} - - async createLockRecord(record: LockRecord): Promise> { - // Implementation using createEntry - } -} - -export const LockEntryRepositoryImpl = RepositoryAbstraction.createImplementation({ - implementation: LockEntryRepositoryImpl, - dependencies: [CreateEntryUseCase] -}); - -// In feature registration -container.register(LockEntryRepositoryImpl).inSingletonScope(); -``` - -**Example: GetLockRecordRepository** - -```typescript -// features/GetLockRecord/GetLockRecordRepository.ts -import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; -import { GetLockRecordRepository as RepositoryAbstraction } from "./abstractions.js"; - -class GetLockRecordRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private getEntryById: GetEntryByIdUseCase.Interface - ) {} - - async getLockRecord(id: string): Promise> { - // Implementation using getEntryById - } -} - -export const GetLockRecordRepositoryImpl = RepositoryAbstraction.createImplementation({ - implementation: GetLockRecordRepositoryImpl, - dependencies: [GetEntryByIdUseCase] -}); -``` - -**Example: ListLockRecordsRepository** - -```typescript -// features/ListLockRecords/ListLockRecordsRepository.ts -import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; -import { ListLockRecordsRepository as RepositoryAbstraction } from "./abstractions.js"; - -class ListLockRecordsRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private listEntries: ListEntriesUseCase.Interface - ) {} - - async listLockRecords(params: ListParams): Promise> { - // Implementation using listEntries - } -} - -export const ListLockRecordsRepositoryImpl = RepositoryAbstraction.createImplementation({ - implementation: ListLockRecordsRepositoryImpl, - dependencies: [ListEntriesUseCase] -}); -``` - -**Key Principle:** Each repository serves only the needs of its associated use case. No god objects or shared repositories. - -### Phase 8: Feature Registration - -**Main feature:** - -```typescript -// features/RecordLockingManagement/feature.ts -import { createFeature } from "@webiny/feature"; -import { Container } from "@webiny/di"; -import { LockEntryFeature } from "../LockEntry/feature.js"; -import { UnlockEntryFeature } from "../UnlockEntry/feature.js"; -import { GetLockRecordFeature } from "../GetLockRecord/feature.js"; -import { ListLockRecordsFeature } from "../ListLockRecords/feature.js"; -import { UpdateEntryLockFeature } from "../UpdateEntryLock/feature.js"; - -export const RecordLockingManagementFeature = createFeature({ - name: "RecordLockingManagement", - register(container: Container) { - // Register sub-features (each registers its own repository in singleton scope) - LockEntryFeature.register(container); - UnlockEntryFeature.register(container); - GetLockRecordFeature.register(container); - ListLockRecordsFeature.register(container); - UpdateEntryLockFeature.register(container); - } -}); -``` - -**Individual feature example:** - -```typescript -// features/LockEntry/feature.ts -import { createFeature } from "@webiny/feature"; -import { Container } from "@webiny/di"; -import { LockEntryUseCaseImpl } from "./LockEntryUseCase.js"; -import { LockEntryRepositoryImpl } from "./LockEntryRepository.js"; - -export const LockEntryFeature = createFeature({ - name: "LockEntry", - register(container: Container) { - // Register repository in singleton scope - container.register(LockEntryRepositoryImpl).inSingletonScope(); - - // Register use case in transient scope (default) - container.register(LockEntryUseCaseImpl); - } -}); -``` - -## Dependencies from CMS Package - -The following use cases will be injected from `@webiny/api-headless-cms`: - -- `GetEntryByIdUseCase` (from `contentEntry/GetEntryById`) -- `CreateEntryUseCase` (from `contentEntry/CreateEntry`) -- `UpdateEntryUseCase` (from `contentEntry/UpdateEntry`) -- `DeleteEntryUseCase` (from `contentEntry/DeleteEntry`) -- `ListEntriesUseCase` (from `contentEntry/ListEntries`) -- `GetModelUseCase` (from `contentModel/GetModel`) - -## Summary of Key Changes - -1. ✅ **Remove `getManager()`** → Inject entry use cases directly -2. ✅ **Remove PubSub topics** → Use EventPublisher with domain events -3. ✅ **Create proper abstractions** for all use cases -4. ✅ **Create domain-specific errors** extending BaseError -5. ✅ **Register in correct scopes:** - - Use cases: Transient scope (default) - - Repositories: Singleton scope - - Gateways: Singleton scope -6. ✅ **Use existing abstractions** from core packages: - - `IdentityContext` from `@webiny/api-core/features/IdentityContext` - - `WebsocketsContext` from `@webiny/api-websockets/features/WebsocketsContext` -7. ✅ **One repository per use case** - No god objects -8. ✅ **One file per class** rule -9. ✅ **Feature-based folder structure** - -## Migration Checklist - -### Shared Components -- [ ] Create `features/shared/` directory structure -- [ ] Create domain errors in `shared/errors.ts` -- [ ] Create `LockRecord` domain model in `shared/LockRecord.ts` - -### LockEntry Feature -- [ ] Create `features/LockEntry/` directory -- [ ] Create abstractions (use case + repository) -- [ ] Create events (BeforeLock, AfterLock, LockError) -- [ ] Implement `LockEntryRepository` (uses CreateEntryUseCase) -- [ ] Implement `LockEntryUseCase` -- [ ] Create feature registration -- [ ] Register repository in singleton scope, use case in transient scope - -### UnlockEntry Feature -- [ ] Create `features/UnlockEntry/` directory -- [ ] Create abstractions (use case + repository) -- [ ] Create events (BeforeUnlock, AfterUnlock, UnlockError) -- [ ] Implement `UnlockEntryRepository` (uses DeleteEntryUseCase) -- [ ] Implement `UnlockEntryUseCase` -- [ ] Create feature registration -- [ ] Register repository in singleton scope, use case in transient scope - -### GetLockRecord Feature -- [ ] Create `features/GetLockRecord/` directory -- [ ] Create abstractions (use case + repository) -- [ ] Implement `GetLockRecordRepository` (uses GetEntryByIdUseCase) -- [ ] Implement `GetLockRecordUseCase` -- [ ] Create feature registration -- [ ] Register repository in singleton scope, use case in transient scope - -### ListLockRecords Feature -- [ ] Create `features/ListLockRecords/` directory -- [ ] Create abstractions (use case + repository) -- [ ] Implement `ListLockRecordsRepository` (uses ListEntriesUseCase) -- [ ] Implement `ListLockRecordsUseCase` -- [ ] Create feature registration -- [ ] Register repository in singleton scope, use case in transient scope - -### UpdateEntryLock Feature -- [ ] Create `features/UpdateEntryLock/` directory -- [ ] Create abstractions (use case + repository) -- [ ] Implement `UpdateEntryLockRepository` (uses UpdateEntryUseCase) -- [ ] Implement `UpdateEntryLockUseCase` -- [ ] Create feature registration -- [ ] Register repository in singleton scope, use case in transient scope - -### IsEntryLocked Feature -- [ ] Create `features/IsEntryLocked/` directory -- [ ] Create abstractions (use case only) -- [ ] Implement `IsEntryLockedUseCase` (uses GetLockRecordUseCase) -- [ ] Create feature registration -- [ ] Register use case in transient scope - -### KickOutCurrentUser Feature -- [ ] Create `features/KickOutCurrentUser/` directory -- [ ] Create abstractions (use case only) -- [ ] Implement `KickOutCurrentUserUseCase` (uses WebsocketsContext) -- [ ] Create feature registration -- [ ] Register use case in transient scope - -### Composite Feature -- [ ] Create `RecordLockingManagement` feature that registers all sub-features - -### Integration & Cleanup -- [ ] Update GraphQL schema to use new features -- [ ] Update `index.ts` to export features and abstractions -- [ ] Remove old `crud/` directory -- [ ] Remove old `useCases/` directory -- [ ] Update tests to use new feature structure -- [ ] Update documentation diff --git a/yarn.lock b/yarn.lock index 77865480020..cb928598b23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14789,9 +14789,9 @@ __metadata: "@webiny/api": "npm:0.0.0" "@webiny/api-core": "npm:0.0.0" "@webiny/api-headless-cms": "npm:0.0.0" + "@webiny/api-scheduler": "npm:0.0.0" "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" @@ -14995,23 +14995,26 @@ __metadata: languageName: unknown linkType: soft -"@webiny/api-scheduler@workspace:packages/api-scheduler": +"@webiny/api-scheduler@npm:0.0.0, @webiny/api-scheduler@workspace:packages/api-scheduler": version: 0.0.0-use.local resolution: "@webiny/api-scheduler@workspace:packages/api-scheduler" dependencies: "@webiny/api": "npm:0.0.0" "@webiny/api-core": "npm:0.0.0" "@webiny/api-headless-cms": "npm:0.0.0" + "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" + "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" - "@webiny/pubsub": "npm:0.0.0" + "@webiny/utils": "npm:0.0.0" "@webiny/wcp": "npm:0.0.0" + aws-sdk-client-mock: "npm:^4.1.0" jest-dynalite: "npm:^3.6.1" rimraf: "npm:^6.0.1" typescript: "npm:5.9.3" From 27c9c70d12d2a5c6d632a13dc8c60dd1fb1cf947 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 18:56:18 +0100 Subject: [PATCH 50/71] wip: use HCMS use cases in tasks package --- ai-context/core-features-reference.md | 5 - .../src/cmsFileStorage/CmsFilesStorage.ts | 6 +- .../src/definitions/entry.ts | 3 - .../src/definitions/group.ts | 3 - .../src/definitions/model.ts | 4 - .../src/definitions/system.ts | 3 - .../elasticsearch/createElasticsearchIndex.ts | 1 - .../src/elasticsearch/indices/japanese.ts | 3 +- .../entry/elasticsearch/filtering/exec.ts | 3 +- .../entry/elasticsearch/initialQuery.ts | 9 - .../entry/elasticsearch/plugins/operator.ts | 10 +- .../src/operations/group/index.ts | 4 - .../src/tasks/createIndexTaskPlugin.ts | 8 +- packages/api-headless-cms-ddb-es/src/types.ts | 2 +- .../__tests__/context/helpers.ts | 5 - .../__tests__/context/useHandler.ts | 1 - .../tasks/mockDataCreatorTask.test.ts | 3 +- .../tasks/mockDataManagerTask.test.ts | 3 +- .../tasks/MockDataManager/MockDataManager.ts | 3 +- .../src/tasks/createMockDataManagerTask.ts | 6 +- .../src/utils/createIndex.ts | 3 +- .../src/utils/disableIndexing.ts | 2 +- .../src/utils/enableIndexing.ts | 2 +- .../ImportFromUrlProcessEntries.ts | 15 +- .../ImportFromUrlProcessEntriesInsert.ts | 26 +-- .../utils/cmsEntryZipper/CmsEntryZipper.ts | 1 - packages/api-headless-cms/src/types/types.ts | 37 ---- .../src/context/pages/PagesStorage.ts | 5 +- .../src/context/pages/pages.types.ts | 1 - .../src/context/redirects/RedirectsStorage.ts | 3 +- .../src/context/redirects/redirects.types.ts | 2 - packages/tasks/__tests__/crud/store.test.ts | 13 +- packages/tasks/src/crud/crud.tasks.ts | 166 +++++++++++++----- packages/tasks/src/domain/errors.ts | 35 ++++ packages/tasks/src/graphql/index.ts | 14 +- 35 files changed, 210 insertions(+), 200 deletions(-) create mode 100644 packages/tasks/src/domain/errors.ts diff --git a/ai-context/core-features-reference.md b/ai-context/core-features-reference.md index bca0920f02a..10290ee2ea0 100644 --- a/ai-context/core-features-reference.md +++ b/ai-context/core-features-reference.md @@ -57,11 +57,6 @@ This document provides the correct import paths and type definitions for commonl - **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/GetEntry/abstractions.ts` - **Usage:** Get single entry by query parameters (where + sort) -#### ListEntries -- **Import:** `import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` -- **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` -- **Usage:** Base abstraction for listing entries with filtering and pagination - #### ListLatestEntries - **Import:** `import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"` - **Interface Type:** See `packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts` diff --git a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts index ac97cb8aad8..b1412f41c0c 100644 --- a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts +++ b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts @@ -50,8 +50,8 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { this.tagsWhereProcessor = new ListTagsWhereProcessor(); } - private modelWithContext({ tenant, locale }: ModelContext): CmsModel { - return { ...this.model, tenant, locale }; + private modelWithContext({ tenant }: ModelContext): CmsModel { + return { ...this.model, tenant }; } async create({ file }: FileManagerFilesStorageOperationsCreateParams): Promise { @@ -175,8 +175,6 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { createdOn: entry.createdOn, modifiedOn: entry.modifiedOn || null, savedOn: entry.savedOn, - - locale: entry.locale, tenant: entry.tenant, webinyVersion: entry.webinyVersion, ...entry.values diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts index 0398b209c10..1dc8641561a 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts @@ -92,9 +92,6 @@ export const createEntryEntity = (params: CreateEntryEntityParams): Entity modelId: { type: "string" }, - locale: { - type: "string" - }, version: { type: "number" }, diff --git a/packages/api-headless-cms-ddb-es/src/definitions/group.ts b/packages/api-headless-cms-ddb-es/src/definitions/group.ts index 1d7b180379e..c477586c359 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/group.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/group.ts @@ -34,9 +34,6 @@ export const createGroupEntity = (params: CreateGroupEntityParams): Entity slug: { type: "string" }, - locale: { - type: "string" - }, description: { type: "string" }, diff --git a/packages/api-headless-cms-ddb-es/src/definitions/model.ts b/packages/api-headless-cms-ddb-es/src/definitions/model.ts index 12a5fe177fc..25271918381 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/model.ts @@ -44,10 +44,6 @@ export const createModelEntity = (params: CreateModelEntityParams): Entity type: "string", required: true }, - locale: { - type: "string", - required: true - }, group: { type: "map", required: true diff --git a/packages/api-headless-cms-ddb-es/src/definitions/system.ts b/packages/api-headless-cms-ddb-es/src/definitions/system.ts index a20ec2f1baa..d0d569305f4 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/system.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/system.ts @@ -23,9 +23,6 @@ export const createSystemEntity = (params: CreateSystemEntityParams): Entity { console.log( diff --git a/packages/api-headless-cms-ddb-es/src/elasticsearch/indices/japanese.ts b/packages/api-headless-cms-ddb-es/src/elasticsearch/indices/japanese.ts index fd8c56b59e7..540bc7786c2 100644 --- a/packages/api-headless-cms-ddb-es/src/elasticsearch/indices/japanese.ts +++ b/packages/api-headless-cms-ddb-es/src/elasticsearch/indices/japanese.ts @@ -2,6 +2,5 @@ import { CmsEntryElasticsearchIndexPlugin } from "~/plugins/CmsEntryElasticsearc import { getJapaneseConfiguration } from "@webiny/api-elasticsearch"; export const japanese = new CmsEntryElasticsearchIndexPlugin({ - body: getJapaneseConfiguration(), - locales: ["ja", "ja-jp"] + body: getJapaneseConfiguration() }); diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/exec.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/exec.ts index 6b4823d492b..125d5b1f534 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/exec.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/filtering/exec.ts @@ -38,8 +38,7 @@ export const createExecFiltering = (params: CreateExecParams): CreateExecFilteri * We need the operator plugins, which we execute on our where conditions. */ const operatorPlugins = createOperatorPluginList({ - plugins, - locale: model.locale + plugins }); const applyFiltering = createApplyFiltering({ diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/initialQuery.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/initialQuery.ts index 3c2c8f12acd..2b1589c831f 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/initialQuery.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/initialQuery.ts @@ -55,15 +55,6 @@ export const createInitialQuery = (params: Params): ElasticsearchBoolQueryConfig "modelId.keyword": model.modelId } }); - /** - * TODO determine if we want to search across locales? - * This search would anyway work for a single model and when sharing index. - */ - query.filter.push({ - term: { - "locale.keyword": model.locale - } - }); } /** diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/plugins/operator.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/plugins/operator.ts index 6f8f3e3ce2b..a830062bd9f 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/plugins/operator.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/elasticsearch/plugins/operator.ts @@ -4,12 +4,11 @@ import type { ElasticsearchQueryBuilderOperatorPlugins } from "../types.js"; interface Params { plugins: PluginsContainer; - locale: string; } export const createOperatorPluginList = ( params: Params ): ElasticsearchQueryBuilderOperatorPlugins => { - const { plugins, locale } = params; + const { plugins } = params; /** * We always set the last one operator plugin added. * This way user can override the plugins. @@ -20,13 +19,6 @@ export const createOperatorPluginList = ( ) .reduce((acc, plugin) => { const operator = plugin.getOperator(); - /** - * We only allow the plugins which can pass the locale test. - * The default plugins always return true. - */ - if (plugin.isLocaleSupported(locale) === false) { - return acc; - } /** * We also only allow the override of the plugins if the new plugin is NOT a default one. * If a user sets the plugin name ending with .default, we cannot do anything about it. diff --git a/packages/api-headless-cms-ddb-es/src/operations/group/index.ts b/packages/api-headless-cms-ddb-es/src/operations/group/index.ts index 69b278c8a5e..be2db9b32b8 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/group/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/group/index.ts @@ -78,7 +78,6 @@ export const createGroupsStorageOperations = ( ...keys } }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not create group.", @@ -103,7 +102,6 @@ export const createGroupsStorageOperations = ( ...keys } }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not update group.", @@ -124,7 +122,6 @@ export const createGroupsStorageOperations = ( entity, keys }); - return group; } catch (ex) { throw new WebinyError( ex.message || "Could not delete group.", @@ -187,7 +184,6 @@ export const createGroupsStorageOperations = ( ...initialWhere }; delete where["tenant"]; - delete where["locale"]; const filteredItems = cleanupItems( entity, diff --git a/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts b/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts index dd4cf811ee6..5a702872d65 100644 --- a/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts +++ b/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts @@ -6,7 +6,7 @@ import type { CmsContext } from "~/types.js"; export const createIndexTaskPluginTest = () => { return createElasticsearchIndexTaskPlugin({ name: "elasticsearch.cms.createIndexTaskPlugin", - getIndexList: async ({ context, locale, tenant }) => { + getIndexList: async ({ context, tenant }) => { const originalTenant = context.tenancy.getCurrentTenant(); if (!originalTenant) { return []; @@ -27,17 +27,13 @@ export const createIndexTaskPluginTest = () => { const { index } = configurations.es({ model: { modelId: model.modelId, - tenant, - locale + tenant } }); return { index, settings: configurations.indexSettings({ context, - model: { - locale - } }) }; }); diff --git a/packages/api-headless-cms-ddb-es/src/types.ts b/packages/api-headless-cms-ddb-es/src/types.ts index 815ee16218f..205a2c01eb5 100644 --- a/packages/api-headless-cms-ddb-es/src/types.ts +++ b/packages/api-headless-cms-ddb-es/src/types.ts @@ -191,7 +191,7 @@ export interface CmsEntryStorageOperations extends BaseCmsEntryStorageOperations } export interface DataLoadersHandlerInterfaceClearAllParams { - model: Pick; + model: Pick; } export interface DataLoadersHandlerInterface { clearAll: (params?: DataLoadersHandlerInterfaceClearAllParams) => void; diff --git a/packages/api-headless-cms-es-tasks/__tests__/context/helpers.ts b/packages/api-headless-cms-es-tasks/__tests__/context/helpers.ts index 7af398a42bc..e391892304f 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/context/helpers.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/context/helpers.ts @@ -2,7 +2,6 @@ import type { IdentityData } from "@webiny/api-core/features/security/IdentityCo export interface PermissionsArg { name: string; - locales?: string[]; rwd?: string; pw?: string; own?: boolean; @@ -47,10 +46,6 @@ export const createPermissions = (permissions?: PermissionsArg[]): PermissionsAr }, { name: "cms.endpoint.preview" - }, - { - name: "content.i18n", - locales: ["en-US", "de-DE"] } ]; }; diff --git a/packages/api-headless-cms-es-tasks/__tests__/context/useHandler.ts b/packages/api-headless-cms-es-tasks/__tests__/context/useHandler.ts index 6f030e52be5..a3e441a80f5 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/context/useHandler.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/context/useHandler.ts @@ -41,7 +41,6 @@ export const useHandler = (params: Params = {}) => path: "/cms/manage/en-US", headers: { "x-webiny-cms-endpoint": "manage", - "x-webiny-cms-locale": "en-US", "x-tenant": "root" }, ...input diff --git a/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataCreatorTask.test.ts b/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataCreatorTask.test.ts index 8b0e49fe197..3884e971062 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataCreatorTask.test.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataCreatorTask.test.ts @@ -50,8 +50,7 @@ describe("mock data creator task", () => { const result = await runner({ webinyTaskId: task.id, - tenant: "root", - locale: "en-US" + tenant: "root" }); await enableIndexing({ diff --git a/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataManagerTask.test.ts b/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataManagerTask.test.ts index 9339da0e059..a16046ed2fb 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataManagerTask.test.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/tasks/mockDataManagerTask.test.ts @@ -31,8 +31,7 @@ describe("mock data manager task", () => { const result = await runner({ webinyTaskId: task.id, - tenant: "root", - locale: "en-US" + tenant: "root" }); expect(result).toMatchObject({ diff --git a/packages/api-headless-cms-es-tasks/src/tasks/MockDataManager/MockDataManager.ts b/packages/api-headless-cms-es-tasks/src/tasks/MockDataManager/MockDataManager.ts index 3afdbb6c068..4dcfae6c7e3 100644 --- a/packages/api-headless-cms-es-tasks/src/tasks/MockDataManager/MockDataManager.ts +++ b/packages/api-headless-cms-es-tasks/src/tasks/MockDataManager/MockDataManager.ts @@ -47,8 +47,7 @@ export class MockDataManager< client: context.elasticsearch, model: { modelId: input.modelId, - tenant: "root", - locale: "en-US" + tenant: "root" } }); return response.done(); diff --git a/packages/api-headless-cms-es-tasks/src/tasks/createMockDataManagerTask.ts b/packages/api-headless-cms-es-tasks/src/tasks/createMockDataManagerTask.ts index a5f58a0bcdf..823ebe2a084 100644 --- a/packages/api-headless-cms-es-tasks/src/tasks/createMockDataManagerTask.ts +++ b/packages/api-headless-cms-es-tasks/src/tasks/createMockDataManagerTask.ts @@ -42,8 +42,7 @@ export const createMockDataManagerTask = () => { client: context.elasticsearch, model: { modelId: CARS_MODEL_ID, - tenant: "root", - locale: "en-US" + tenant: "root" } }); }, @@ -52,8 +51,7 @@ export const createMockDataManagerTask = () => { client: context.elasticsearch, model: { modelId: CARS_MODEL_ID, - tenant: "root", - locale: "en-US" + tenant: "root" } }); } diff --git a/packages/api-headless-cms-es-tasks/src/utils/createIndex.ts b/packages/api-headless-cms-es-tasks/src/utils/createIndex.ts index 267812909f2..96cc7f67a05 100644 --- a/packages/api-headless-cms-es-tasks/src/utils/createIndex.ts +++ b/packages/api-headless-cms-es-tasks/src/utils/createIndex.ts @@ -7,7 +7,7 @@ import type { PluginsContainer } from "@webiny/plugins"; export interface ICreateIndexParams { client: Client; - model: Pick; + model: Pick; plugins: PluginsContainer; } @@ -28,7 +28,6 @@ export const createIndex = async (params: ICreateIndexParams): Promise => await baseCreateIndex({ index, client, - locale: model.locale, tenant: model.tenant, plugins, type: CmsEntryElasticsearchIndexPlugin.type diff --git a/packages/api-headless-cms-es-tasks/src/utils/disableIndexing.ts b/packages/api-headless-cms-es-tasks/src/utils/disableIndexing.ts index 31d5ef95a8b..64ac7de18d2 100644 --- a/packages/api-headless-cms-es-tasks/src/utils/disableIndexing.ts +++ b/packages/api-headless-cms-es-tasks/src/utils/disableIndexing.ts @@ -4,7 +4,7 @@ import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; export interface IDisableIndexingParams { client: Client; - model: Pick; + model: Pick; } export const disableIndexing = async (params: IDisableIndexingParams) => { diff --git a/packages/api-headless-cms-es-tasks/src/utils/enableIndexing.ts b/packages/api-headless-cms-es-tasks/src/utils/enableIndexing.ts index 4998624c722..eda6f9528ab 100644 --- a/packages/api-headless-cms-es-tasks/src/utils/enableIndexing.ts +++ b/packages/api-headless-cms-es-tasks/src/utils/enableIndexing.ts @@ -4,7 +4,7 @@ import { configurations } from "@webiny/api-headless-cms-ddb-es/configurations.j interface IEnableIndexingParams { client: Client; - model: Pick; + model: Pick; } export const enableIndexing = async (params: IEnableIndexingParams) => { diff --git a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntries.ts b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntries.ts index fdb3c73bb93..2edb1e73530 100644 --- a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntries.ts +++ b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntries.ts @@ -6,11 +6,12 @@ import type { IImportFromUrlProcessEntriesOutput } from "./abstractions/ImportFromUrlProcessEntries.js"; import type { ITaskResponseResult, ITaskRunParams } from "@webiny/tasks"; -import type { ICmsEntryManager } from "@webiny/api-headless-cms/types/index.js"; import { ImportFromUrlProcessEntriesDecompress } from "~/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntriesDecompress.js"; import type { IFileFetcher } from "~/tasks/utils/fileFetcher/index.js"; import { ImportFromUrlProcessEntriesInsert } from "./ImportFromUrlProcessEntriesInsert.js"; import type { ICompressedFileReader, IDecompressor } from "~/tasks/utils/decompressor/index.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; export interface IImportFromUrlProcessEntriesParams { fileFetcher: IFileFetcher; @@ -37,6 +38,9 @@ export class ImportFromUrlProcessEntries< public async run(params: ITaskRunParams): Promise> { const { context, response, input } = params; + const getModel = context.container.resolve(GetModelUseCase); + const createEntry = context.container.resolve(CreateEntryUseCase); + if (!input.modelId) { return response.error({ message: `Missing "modelId" in the input.`, @@ -54,10 +58,8 @@ export class ImportFromUrlProcessEntries< }); } - let entryManager: ICmsEntryManager; - try { - entryManager = await context.cms.getEntryManager(input.modelId); - } catch { + const modelResult = await getModel.execute(input.modelId); + if (modelResult.isFail()) { return response.error({ message: `Model "${input.modelId}" not found.`, code: "MODEL_NOT_FOUND" @@ -85,7 +87,8 @@ export class ImportFromUrlProcessEntries< try { const insert = new ImportFromUrlProcessEntriesInsert({ - entryManager, + model: modelResult.value, + createEntry, fileFetcher: this.fileFetcher }); return await insert.run(params); diff --git a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntriesInsert.ts b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntriesInsert.ts index bc84804fe71..82a47ae0139 100644 --- a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntriesInsert.ts +++ b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessEntries/ImportFromUrlProcessEntriesInsert.ts @@ -9,14 +9,16 @@ import type { IImportFromUrlProcessEntriesInsertProcessedFileInput, IImportFromUrlProcessEntriesOutput } from "./abstractions/ImportFromUrlProcessEntries.js"; -import type { ICmsEntryManager } from "@webiny/api-headless-cms/types/index.js"; import type { Context } from "~/types.js"; import { MANIFEST_JSON } from "~/tasks/constants.js"; import type { IFileFetcher } from "~/tasks/utils/fileFetcher/index.js"; import type { ICmsEntryEntriesJson } from "~/tasks/utils/types.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/index.js"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; export interface IImportFromUrlProcessEntriesInsertParams { - entryManager: ICmsEntryManager; + model: CmsModel; + createEntry: CreateEntryUseCase.Interface; fileFetcher: IFileFetcher; } @@ -26,11 +28,13 @@ export class ImportFromUrlProcessEntriesInsert< O extends IImportFromUrlProcessEntriesOutput = IImportFromUrlProcessEntriesOutput > implements IImportFromUrlProcessEntriesInsert { - private readonly entryManager: ICmsEntryManager; + private readonly createEntry: CreateEntryUseCase.Interface; private readonly fileFetcher: IFileFetcher; + private readonly model: CmsModel; public constructor(params: IImportFromUrlProcessEntriesInsertParams) { - this.entryManager = params.entryManager; + this.model = params.model; + this.createEntry = params.createEntry; this.fileFetcher = params.fileFetcher; } @@ -103,15 +107,17 @@ export class ImportFromUrlProcessEntriesInsert< } }); } - try { - await this.entryManager.create(item); - success++; - } catch (ex) { - console.error(`Failed to insert entry "${item.id}"`, ex); + + const createResult = await this.createEntry.execute(this.model, item); + + if (createResult.isFail()) { + console.error(`Failed to insert entry "${item.id}"`, createResult.error); errors.push({ id: item.id, - message: ex.message + message: createResult.error.message }); + } else { + success++; } } processed.push({ diff --git a/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts b/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts index 67e8df5b7bd..0878c4536d6 100644 --- a/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts +++ b/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts @@ -36,7 +36,6 @@ const createBufferData = (params: ICmsEntryEntriesJson) => { * We need to remove some fields that are not needed in the export. */ delete item.tenant; - delete item.locale; delete item.locked; delete item.webinyVersion; delete item.version; diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 2fc4d12e0cc..8e4032ceeae 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -1,4 +1,3 @@ -import type { Plugin } from "@webiny/plugins/types.js"; import type { Context, GenericRecord } from "@webiny/api/types.js"; import type { GraphQLFieldResolver, @@ -384,32 +383,6 @@ export interface CmsGroupContext { onGroupDeleteError: Topic; } -/** - * A plugin to load a CmsModelManager. - * - * @see CmsModelManager - * - * @category Plugin - * @category CmsModel - * @category CmsEntry - */ -export interface ModelManagerPlugin extends Plugin { - /** - * A plugin type. - */ - type: "cms-content-model-manager"; - /** - * Specific model CmsModelManager loader. Can target exact modelId(s). - * Be aware that if you define multiple plugins without `modelId`, last one will run. - */ - modelId?: string[] | string; - /** - * Create a CmsModelManager for specific type - or new default one. - * For reference in how is this plugin run check [contentModelManagerFactory](https://github.com/webiny/webiny-js/blob/f15676/packages/api-headless-cms/src/content/plugins/CRUD/contentModel/contentModelManagerFactory.ts) - */ - create(context: CmsContext, model: CmsModel): Promise>; -} - /** * A content entry values definition for and from the database. * @@ -703,8 +676,6 @@ export interface CmsModelManager { delete(id: string, options?: CmsDeleteEntryOptions): Promise; } -export type ICmsEntryManager = CmsModelManager; - /** * Create */ @@ -792,14 +763,6 @@ export interface OnModelInitializeParams { data: Record; } -/** - * - */ -export interface CmsModelUpdateDirectParams { - model: CmsModel; - original: CmsModel; -} - export interface ICmsModelListParams { /** * Defaults to true. diff --git a/packages/api-website-builder/src/context/pages/PagesStorage.ts b/packages/api-website-builder/src/context/pages/PagesStorage.ts index a4c674826fd..c440b2c58e3 100644 --- a/packages/api-website-builder/src/context/pages/PagesStorage.ts +++ b/packages/api-website-builder/src/context/pages/PagesStorage.ts @@ -95,7 +95,7 @@ export class PagesStorage implements WbPagesStorageOperations { public update = async ({ id, data }: WbPagesStorageOperationsUpdateParams): Promise => { const entry = await this.cms.getEntryById(this.getModel(), id); - const values = omit(data, ["id", "tenant", "locale", "webinyVersion"]); + const values = omit(data, ["id", "tenant", "webinyVersion"]); const updatedEntry = await this.cms.updateEntry(this.getModel(), entry.id, values); @@ -128,7 +128,7 @@ export class PagesStorage implements WbPagesStorageOperations { }; private getModel() { - return { ...this.model, tenant: this.getTenantId(), locale: this.getLocaleCode() }; + return { ...this.model, tenant: this.getTenantId() }; } private getWbPageFieldValues(entry: CmsEntry) { @@ -146,7 +146,6 @@ export class PagesStorage implements WbPagesStorageOperations { modifiedBy: entry.modifiedBy, locked: entry.locked, tenant: entry.tenant, - locale: entry.locale, webinyVersion: entry.webinyVersion, ...entry.values } as WbPage; diff --git a/packages/api-website-builder/src/context/pages/pages.types.ts b/packages/api-website-builder/src/context/pages/pages.types.ts index 4e1ee4aa68b..79caac69029 100644 --- a/packages/api-website-builder/src/context/pages/pages.types.ts +++ b/packages/api-website-builder/src/context/pages/pages.types.ts @@ -21,7 +21,6 @@ export interface WbPage { modifiedOn: string; modifiedBy: WbIdentity; tenant: string; - locale: string; webinyVersion: string; properties: Record; diff --git a/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts b/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts index fae66bc8f73..2da8130be27 100644 --- a/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts +++ b/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts @@ -67,7 +67,7 @@ export class RedirectsStorage implements WbRedirectsStorageOperations { }: WbRedirectsStorageOperationsUpdateParams): Promise => { const entry = await this.cms.getEntryById(this.model, id + "#0001"); - const values = omit(data, ["id", "tenant", "locale", "webinyVersion"]); + const values = omit(data, ["id", "tenant", "webinyVersion"]); const updatedEntry = await this.cms.updateEntry(this.model, entry.id, values); @@ -98,7 +98,6 @@ export class RedirectsStorage implements WbRedirectsStorageOperations { modifiedOn: entry.modifiedOn, modifiedBy: entry.modifiedBy, tenant: entry.tenant, - locale: entry.locale, redirectFrom: entry.values.redirectFrom, redirectTo: entry.values.redirectTo, redirectType: entry.values.redirectType, diff --git a/packages/api-website-builder/src/context/redirects/redirects.types.ts b/packages/api-website-builder/src/context/redirects/redirects.types.ts index 44d08507c31..1a16aa01671 100644 --- a/packages/api-website-builder/src/context/redirects/redirects.types.ts +++ b/packages/api-website-builder/src/context/redirects/redirects.types.ts @@ -16,8 +16,6 @@ export interface WbRedirect { modifiedOn: string; modifiedBy: WbIdentity; tenant: string; - locale: string; - redirectFrom: string; redirectTo: string; redirectType: string; diff --git a/packages/tasks/__tests__/crud/store.test.ts b/packages/tasks/__tests__/crud/store.test.ts index 6b532414498..9da42afa45c 100644 --- a/packages/tasks/__tests__/crud/store.test.ts +++ b/packages/tasks/__tests__/crud/store.test.ts @@ -1,11 +1,11 @@ import { describe, it, expect } from "vitest"; +import WebinyError from "@webiny/error"; import { useRawHandler } from "~tests/helpers/useRawHandler"; import { createTaskDefinition } from "~/task"; import type { ITask } from "~/types"; import { TaskDataStatus } from "~/types"; -import { NotFoundError } from "@webiny/handler-graphql"; -import WebinyError from "@webiny/error"; import { createMockIdentity } from "~tests/mocks/identity"; +import { TaskDefinitionNotFoundError, TaskNotFoundError } from "~/domain/errors.js"; describe("store crud", () => { const handler = useRawHandler({ @@ -60,12 +60,7 @@ describe("store crud", () => { result = ex; } - expect(result).toBeInstanceOf(WebinyError); - expect(result!.message).toEqual("There is no task definition."); - expect(result!.code).toEqual("TASK_DEFINITION_ERROR"); - expect(result!.data).toEqual({ - id: "non-existing-definition" - }); + expect(result).toBeInstanceOf(TaskDefinitionNotFoundError); }); it("should fail on updating a task which does not exist", async () => { @@ -79,7 +74,7 @@ describe("store crud", () => { result = ex; } - expect(result).toBeInstanceOf(NotFoundError); + expect(result).toBeInstanceOf(TaskNotFoundError); }); it("should create, update and delete a task", async () => { diff --git a/packages/tasks/src/crud/crud.tasks.ts b/packages/tasks/src/crud/crud.tasks.ts index 67362ee2245..6fae3620f81 100644 --- a/packages/tasks/src/crud/crud.tasks.ts +++ b/packages/tasks/src/crud/crud.tasks.ts @@ -29,6 +29,18 @@ import { remapWhere } from "./where.js"; import { createZodError, parseIdentifier } from "@webiny/utils"; import zod from "zod"; import type { GenericRecord } from "@webiny/api/types.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { + TaskDefinitionNotFoundError, + TaskLogNotFoundError, + TaskNotFoundError +} from "~/domain/errors.js"; const createRevisionId = (id: string) => { const { id: entryId } = parseIdentifier(id); @@ -119,22 +131,26 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { const onTaskAfterDelete = createTopic("tasks.onAfterDelete"); const getTaskModel = async (): Promise => { - return await context.security.withoutAuthorization(async () => { - const model = await context.cms.getModel(WEBINY_TASK_MODEL_ID); - if (model) { - return model; + const identityContext = context.container.resolve(IdentityContext); + return await identityContext.withoutAuthorization(async () => { + const getModel = context.container.resolve(GetModelUseCase); + const result = await getModel.execute(WEBINY_TASK_MODEL_ID); + if (result.isFail()) { + throw new WebinyError(`There is no model "${WEBINY_TASK_MODEL_ID}".`); } - throw new WebinyError(`There is no model "${WEBINY_TASK_MODEL_ID}".`); + return result.value; }); }; const getLogModel = async (): Promise => { - return await context.security.withoutAuthorization(async () => { - const model = await context.cms.getModel(WEBINY_TASK_LOG_MODEL_ID); - if (model) { - return model; + const identityContext = context.container.resolve(IdentityContext); + return await identityContext.withoutAuthorization(async () => { + const getModel = context.container.resolve(GetModelUseCase); + const result = await getModel.execute(WEBINY_TASK_LOG_MODEL_ID); + if (result.isFail()) { + throw new WebinyError(`There is no model "${WEBINY_TASK_LOG_MODEL_ID}".`); } - throw new WebinyError(`There is no model "${WEBINY_TASK_LOG_MODEL_ID}".`); + return result.value; }); }; @@ -144,18 +160,17 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { >( id: string ) => { - let entry: CmsEntry; - try { - entry = await context.security.withoutAuthorization(async () => { - const model = await getTaskModel(); - return await context.cms.getEntryById(model, createRevisionId(id)); - }); - } catch (ex) { - if (ex instanceof NotFoundError) { + const identityContext = context.container.resolve(IdentityContext); + + const entry = await identityContext.withoutAuthorization(async () => { + const model = await getTaskModel(); + const getEntryById = context.container.resolve(GetEntryByIdUseCase); + const result = await getEntryById.execute(model, createRevisionId(id)); + if (result.isFail()) { return null; } - throw ex; - } + return result.value; + }); if (!entry) { return null; @@ -170,12 +185,18 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { >( params?: IListTaskParams ) => { - const [items, meta] = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const [items, meta] = await identityContext.withoutAuthorization(async () => { const model = await getTaskModel(); - return await context.cms.listLatestEntries>(model, { + const listLatestEntries = context.container.resolve(ListLatestEntriesUseCase); + const result = await listLatestEntries.execute>(model, { ...params, where: remapWhere(params?.where) }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); return { @@ -187,9 +208,7 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { const createTask = async (data: ITaskCreateData) => { const definition = context.tasks.getDefinition(data.definitionId); if (!definition) { - throw new WebinyError(`There is no task definition.`, "TASK_DEFINITION_ERROR", { - id: data.definitionId - }); + throw new TaskDefinitionNotFoundError(data.definitionId); } await validateTaskInput({ @@ -198,13 +217,19 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { data }); - const entry = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const entry = await identityContext.withoutAuthorization(async () => { const model = await getTaskModel(); - return await context.cms.createEntry(model, { + const createEntry = context.container.resolve(CreateEntryUseCase); + const result = await createEntry.execute(model, { ...data, iterations: 0, taskStatus: TaskDataStatus.PENDING }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); return convertToTask(entry as unknown as CmsEntry); @@ -217,59 +242,98 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { id: string, data: ITaskUpdateData ) => { - const entry = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const entry = await identityContext.withoutAuthorization(async () => { const model = await getTaskModel(); - return await context.cms.updateEntry(model, createRevisionId(id), { + const updateEntry = context.container.resolve(UpdateEntryUseCase); + const result = await updateEntry.execute(model, createRevisionId(id), { ...data, savedOn: new Date().toISOString() }); + + if (result.isFail()) { + return null; + } + + return result.value; }); + + if (!entry) { + throw new TaskNotFoundError(); + } + return convertToTask(entry as unknown as CmsEntry>); }; const deleteTask = async (id: string) => { - return context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + return identityContext.withoutAuthorization(async () => { const model = await getTaskModel(); - await context.cms.deleteEntry(model, createRevisionId(id)); + const deleteEntry = context.container.resolve(DeleteEntryUseCase); + const result = await deleteEntry.execute(model, createRevisionId(id)); + if (result.isFail()) { + throw new TaskNotFoundError(); + } return true; }); }; const createLog = async (task: Pick, data: ITaskLogCreateInput) => { - const entry = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const entry = await identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - - return await context.cms.createEntry(model, { + const createEntry = context.container.resolve(CreateEntryUseCase); + const result = await createEntry.execute(model, { ...data, task: task.id }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); return convertToLog(entry as unknown as CmsEntry); }; const updateLog = async (id: string, data: ITaskLogUpdateInput) => { - const entry = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const entry = await identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - - return await context.cms.updateEntry(model, createRevisionId(id), data); + const updateEntry = context.container.resolve(UpdateEntryUseCase); + const result = await updateEntry.execute(model, createRevisionId(id), data); + if (result.isFail()) { + throw new TaskLogNotFoundError(); + } + return result.value; }); return convertToLog(entry as unknown as CmsEntry); }; const deleteLog = async (id: string) => { - return context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + return identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - await context.cms.deleteEntry(model, id); + const deleteEntry = context.container.resolve(DeleteEntryUseCase); + const result = await deleteEntry.execute(model, id); + if (result.isFail()) { + throw new TaskLogNotFoundError(); + } return true; }); }; const getLog = async (id: string): Promise => { + const identityContext = context.container.resolve(IdentityContext); try { - const entry = await context.security.withoutAuthorization(async () => { + const entry = await identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - return await context.cms.getEntryById(model, id); + const getEntryById = context.container.resolve(GetEntryByIdUseCase); + const result = await getEntryById.execute(model, id); + if (result.isFail()) { + throw result.error; + } + return result.value; }); return convertToLog(entry as unknown as CmsEntry); @@ -282,15 +346,21 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { }; const getLatestLog = async (taskId: string): Promise => { - const entry = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const entry = await identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - const [items] = await context.cms.listLatestEntries(model, { + const listLatestEntries = context.container.resolve(ListLatestEntriesUseCase); + const result = await listLatestEntries.execute(model, { where: { task: taskId }, sort: ["createdOn_DESC"], limit: 1 }); + if (result.isFail()) { + throw result.error; + } + const [items] = result.value; const [item] = items; if (!item) { throw new NotFoundError(`No existing latest log found for task "${taskId}".`); @@ -302,12 +372,18 @@ export const createTaskCrud = (context: Context): ITasksContextCrudObject => { }; const listLogs = async (params: IListTaskLogParams) => { - const [items, meta] = await context.security.withoutAuthorization(async () => { + const identityContext = context.container.resolve(IdentityContext); + const [items, meta] = await identityContext.withoutAuthorization(async () => { const model = await getLogModel(); - return await context.cms.listLatestEntries(model, { + const listLatestEntries = context.container.resolve(ListLatestEntriesUseCase); + const result = await listLatestEntries.execute(model, { ...params, where: remapWhere(params.where) }); + if (result.isFail()) { + throw result.error; + } + return result.value; }); return { diff --git a/packages/tasks/src/domain/errors.ts b/packages/tasks/src/domain/errors.ts new file mode 100644 index 00000000000..5873b123a15 --- /dev/null +++ b/packages/tasks/src/domain/errors.ts @@ -0,0 +1,35 @@ +import { BaseError } from "@webiny/feature/api"; + +export class TaskDefinitionNotFoundError extends BaseError<{ id: string }> { + override readonly code = "BackgroundTasks/TaskDefinition/NotFoundError" as const; + + constructor(id: string) { + super({ + message: "Task definition not found.", + data: { + id + } + }); + } +} + + +export class TaskNotFoundError extends BaseError { + override readonly code = "BackgroundTasks/Task/NotFoundError" as const; + + constructor() { + super({ + message: "Task not found." + }); + } +} + +export class TaskLogNotFoundError extends BaseError { + override readonly code = "BackgroundTasks/Log/NotFoundError" as const; + + constructor() { + super({ + message: "Task log not found." + }); + } +} diff --git a/packages/tasks/src/graphql/index.ts b/packages/tasks/src/graphql/index.ts index 57f3675ed8c..c4f8d8ace64 100644 --- a/packages/tasks/src/graphql/index.ts +++ b/packages/tasks/src/graphql/index.ts @@ -15,6 +15,7 @@ import { emptyResolver, resolve, resolveList } from "./utils.js"; import { renderFields } from "@webiny/api-headless-cms/utils/renderFields.js"; import { checkPermissions } from "./checkPermissions.js"; import type { Plugin } from "@webiny/plugins/types.js"; +import { ListModelsUseCase } from "@webiny/api-headless-cms/features/contentModel/ListModels/index.js"; interface IGetTaskQueryParams { id: string; @@ -57,15 +58,12 @@ const createGraphQL = () => { const taskModel = await ctx.tasks.getTaskModel(); const logModel = await ctx.tasks.getLogModel(); + const listModels = ctx.container.resolve(ListModelsUseCase); + const models = await ctx.security.withoutAuthorization(async () => { - return (await ctx.cms.listModels()).filter(model => { - if (model.fields.length === 0) { - return false; - } else if (model.isPrivate) { - return false; - } - return true; - }); + const modelsResult = await listModels.execute({ includePrivate: false }); + + return modelsResult.value.filter(model => model.fields.length > 0); }); const fieldTypePlugins = createFieldTypePluginRecords(ctx.plugins); From 7364013fe8942f3dba7f23f61d44d296da42bbd3 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Thu, 20 Nov 2025 19:22:40 +0100 Subject: [PATCH 51/71] wip --- .../__tests__/utils/useGraphQlHandler.ts | 1 - .../operations/AliasesStorageOperations.ts | 9 +-- .../customAssets/S3CustomAssetResolver.ts | 1 - .../assetDelivery/s3/S3AssetMetadataReader.ts | 2 - .../src/assetDelivery/s3/S3AssetResolver.ts | 1 - .../createThreatDetectionEventHandler.ts | 3 +- .../src/plugins/addFileMetadata.ts | 1 - .../__tests__/file.lifecycle.test.ts | 1 - .../__tests__/utils/plugins.ts | 1 - .../src/cmsFileStorage/CmsFilesStorage.ts | 13 ++-- .../cmsFileStorage/ListFilesWhereProcessor.ts | 2 +- .../cmsFileStorage/ListTagsWhereProcessor.ts | 2 +- .../src/createFileManager/files.crud.ts | 18 ++--- .../src/createFileManager/types.ts | 1 - .../src/delivery/AssetDelivery/Asset.ts | 4 -- .../src/delivery/setupAssetDelivery.ts | 7 +- packages/api-file-manager/src/types.ts | 2 - packages/api-file-manager/src/types/file.ts | 2 - .../useCases/validateImportFromUrl.test.ts | 23 +++---- .../src/crud/index.ts | 3 +- .../ValidateImportFromUrlUseCase.ts | 9 +-- .../crud/utils/makeSureModelsAreIdentical.ts | 18 ++--- .../contentModel/ContentModelFeature.ts | 5 +- .../ModelToAstConverter.ts | 2 +- .../ModelToAstConverter/abstractions.ts | 15 +++++ .../ModelToAstConverter/feature.ts | 9 +++ .../contentModel/ModelToAstConverter/index.ts | 1 + .../contentModel/shared/abstractions.ts | 15 +---- packages/api-headless-cms/src/index.ts | 2 - .../modelManager/DefaultCmsModelManager.ts | 67 ------------------- .../src/modelManager/SingletonModelManager.ts | 63 ----------------- .../src/modelManager/index.ts | 13 ---- 32 files changed, 71 insertions(+), 245 deletions(-) rename packages/api-headless-cms/src/features/contentModel/{shared => ModelToAstConverter}/ModelToAstConverter.ts (96%) create mode 100644 packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/feature.ts create mode 100644 packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/index.ts delete mode 100644 packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts delete mode 100644 packages/api-headless-cms/src/modelManager/SingletonModelManager.ts delete mode 100644 packages/api-headless-cms/src/modelManager/index.ts diff --git a/packages/api-file-manager-aco/__tests__/utils/useGraphQlHandler.ts b/packages/api-file-manager-aco/__tests__/utils/useGraphQlHandler.ts index 20535d5cd18..607bd803f69 100644 --- a/packages/api-file-manager-aco/__tests__/utils/useGraphQlHandler.ts +++ b/packages/api-file-manager-aco/__tests__/utils/useGraphQlHandler.ts @@ -67,7 +67,6 @@ export const useGraphQlHandler = (params: UseGQLHandlerParams = {}) => { ...createTenancyAndSecurity({ permissions, identity: identity || defaultIdentity }), new CmsParametersPlugin(async () => { return { - locale: "en-US", type: "manage" }; }), diff --git a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts index 038a9d22a14..b9e8fb572fb 100644 --- a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts +++ b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts @@ -18,7 +18,6 @@ interface AliasesStorageOperationsConfig { } interface CreatePartitionKeyParams { - locale: string; tenant: string; id: string; } @@ -45,8 +44,7 @@ export class AliasesStorageOperations implements FileManagerAliasesStorageOperat return { PK: this.createPartitionKey({ id: item.fileId, - tenant: item.tenant, - locale: item.locale + tenant: item.tenant }), SK: `ALIAS#${item.alias}` }; @@ -93,8 +91,8 @@ export class AliasesStorageOperations implements FileManagerAliasesStorageOperat } private createPartitionKey(params: CreatePartitionKeyParams): string { - const { tenant, locale, id } = params; - return `T#${tenant}#L#${locale}#FM#F${id}`; + const { tenant, id } = params; + return `T#${tenant}#FM#F${id}`; } private createNewAliasesRecords( @@ -118,7 +116,6 @@ export class AliasesStorageOperations implements FileManagerAliasesStorageOperat data: { alias, tenant: file.tenant, - locale: file.locale, fileId: file.id, key: file.key } diff --git a/packages/api-file-manager-s3/src/assetDelivery/customAssets/S3CustomAssetResolver.ts b/packages/api-file-manager-s3/src/assetDelivery/customAssets/S3CustomAssetResolver.ts index b2091f57531..6ea0111fb9e 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/customAssets/S3CustomAssetResolver.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/customAssets/S3CustomAssetResolver.ts @@ -50,7 +50,6 @@ export class S3CustomAssetResolver implements AssetResolver { // These attributes do not change between the original and derived files. id: metadata.id, tenant: metadata.tenant, - locale: metadata.locale, // Assign the size and content type of the requested file. size: attrs.size, contentType: attrs.contentType, diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts index 3ec28ef0cb3..e04202841d8 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetMetadataReader.ts @@ -3,7 +3,6 @@ import type { S3 } from "@webiny/aws-sdk/client-s3/index.js"; interface AssetMetadata { id: string; tenant: string; - locale: string; size: number; contentType: string; } @@ -36,7 +35,6 @@ export class S3AssetMetadataReader { return { id: metadata.id, tenant: metadata.tenant, - locale: metadata.locale, size: metadata.size, contentType: metadata.contentType }; diff --git a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts index 520417cc283..c26aca97dfb 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/s3/S3AssetResolver.ts @@ -21,7 +21,6 @@ export class S3AssetResolver implements AssetResolver { const asset = new Asset({ id: metadata.id, tenant: metadata.tenant, - locale: metadata.locale, size: metadata.size, contentType: metadata.contentType, key: request.getKey() diff --git a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts index a4dfa34d613..94bf947feb0 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts @@ -32,8 +32,7 @@ export const createThreatDetectionEventHandler = () => { request.headers = { ...request.headers, - "x-tenant": metadata.tenant, - "x-i18n-locale": `default:${metadata.locale};content:${metadata.locale};` + "x-tenant": metadata.tenant }; } catch { // If metadata can't be loaded, we ignore the file. diff --git a/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts b/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts index 58798430283..2a775d12780 100644 --- a/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts +++ b/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts @@ -41,7 +41,6 @@ export class MetadataWriter { return { id: file.id, tenant: file.tenant, - locale: file.locale, size: file.size, contentType: file.type }; diff --git a/packages/api-file-manager/__tests__/file.lifecycle.test.ts b/packages/api-file-manager/__tests__/file.lifecycle.test.ts index 594e8961afe..d60379cb859 100644 --- a/packages/api-file-manager/__tests__/file.lifecycle.test.ts +++ b/packages/api-file-manager/__tests__/file.lifecycle.test.ts @@ -40,7 +40,6 @@ describe("File lifecycle events", () => { type: "admin" }, tenant: "root", - locale: "en-US", meta: { private: false }, diff --git a/packages/api-file-manager/__tests__/utils/plugins.ts b/packages/api-file-manager/__tests__/utils/plugins.ts index 3af3c5c3054..b70bb15b2de 100644 --- a/packages/api-file-manager/__tests__/utils/plugins.ts +++ b/packages/api-file-manager/__tests__/utils/plugins.ts @@ -46,7 +46,6 @@ export const handlerPlugins = (params: HandlerParams) => { ...createTenancyAndSecurity({ permissions, identity }), new CmsParametersPlugin(async () => { return { - locale: "en-US", type: "manage" }; }), diff --git a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts index b1412f41c0c..77c13872b60 100644 --- a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts +++ b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts @@ -20,7 +20,6 @@ import { ROOT_FOLDER } from "~/contants.js"; interface ModelContext { tenant: string; - locale: string; } export class CmsFilesStorage implements FileManagerFilesStorageOperations { @@ -92,8 +91,8 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { } async get({ where }: FileManagerFilesStorageOperationsGetParams): Promise { - const { id, tenant, locale } = where; - const model = this.modelWithContext({ tenant, locale }); + const { id, tenant } = where; + const model = this.modelWithContext({ tenant }); const entry = await this.cms.getEntry(model, { where: { entryId: id, latest: true } }); return entry ? this.getFileFieldValues(entry) : null; } @@ -102,9 +101,8 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { params: FileManagerFilesStorageOperationsListParams ): Promise { const tenant = params.where.tenant; - const locale = params.where.locale; - const model = this.modelWithContext({ tenant, locale }); + const model = this.modelWithContext({ tenant }); const where = this.filesWhereProcessor.process(params.where); const [entries, meta] = await this.cms.listLatestEntries(model, { @@ -122,8 +120,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { params: FileManagerFilesStorageOperationsTagsParams ): Promise { const tenant = params.where.tenant; - const locale = params.where.locale; - const model = this.modelWithContext({ tenant, locale }); + const model = this.modelWithContext({ tenant}); const uniqueValues = await this.cms.getUniqueFieldValues(model, { fieldId: "tags", where: { @@ -152,7 +149,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { where: { entryId: file.id, latest: true } }); - const values = omit(file, ["id", "tenant", "locale", "webinyVersion"]); + const values = omit(file, ["id", "tenant", "webinyVersion"]); const updatedEntry = await this.cms.updateEntry(model, entry.id, { ...values, diff --git a/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts b/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts index 8dbbaa94a56..b98bd66ef14 100644 --- a/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts +++ b/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts @@ -5,7 +5,7 @@ type StandardFileKey = keyof FileManagerFilesStorageOperationsListParamsWhere; type CmsEntryListWhereKey = keyof CmsEntryListWhere; export class ListFilesWhereProcessor { - private readonly skipKeys = ["tenant", "locale"]; + private readonly skipKeys = ["tenant"]; private readonly keyMap: Partial> = { id: "entryId", id_in: "entryId_in" diff --git a/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts b/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts index 2c70243b928..c499e3f8c3b 100644 --- a/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts +++ b/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts @@ -5,7 +5,7 @@ type StandardFileKey = keyof FileManagerFilesStorageOperationsListParamsWhere; type CmsEntryListWhereKey = keyof CmsEntryListWhere; export class ListTagsWhereProcessor { - private readonly skipKeys = ["tenant", "locale"]; + private readonly skipKeys = ["tenant"]; private readonly keyMap: Partial> = { tag_startsWith: "tags_startsWith", tag_not_startsWith: "tags_not_startsWith" diff --git a/packages/api-file-manager/src/createFileManager/files.crud.ts b/packages/api-file-manager/src/createFileManager/files.crud.ts index ae9d916703c..b657e24a61a 100644 --- a/packages/api-file-manager/src/createFileManager/files.crud.ts +++ b/packages/api-file-manager/src/createFileManager/files.crud.ts @@ -20,7 +20,6 @@ export const createFilesCrud = ( FileManagerConfig, | "storageOperations" | "filesPermissions" - | "getLocaleCode" | "getTenantId" | "getIdentity" | "WEBINY_VERSION" @@ -29,7 +28,6 @@ export const createFilesCrud = ( const { storageOperations, filesPermissions, - getLocaleCode, getTenantId, getIdentity, WEBINY_VERSION @@ -50,8 +48,7 @@ export const createFilesCrud = ( const file = await storageOperations.files.get({ where: { id, - tenant: getTenantId(), - locale: getLocaleCode() + tenant: getTenantId() } }); @@ -93,7 +90,6 @@ export const createFilesCrud = ( savedBy: utilsGetIdentity(input.savedBy, currentIdentity)!, tenant: getTenantId(), - locale: getLocaleCode(), webinyVersion: WEBINY_VERSION }; @@ -126,8 +122,7 @@ export const createFilesCrud = ( const original = await storageOperations.files.get({ where: { id, - tenant: getTenantId(), - locale: getLocaleCode() + tenant: getTenantId() } }); @@ -201,8 +196,7 @@ export const createFilesCrud = ( const file = await storageOperations.files.get({ where: { id, - tenant: getTenantId(), - locale: getLocaleCode() + tenant: getTenantId() } }); @@ -238,7 +232,6 @@ export const createFilesCrud = ( await filesPermissions.ensure({ rwd: "w" }); const tenant = getTenantId(); - const locale = getLocaleCode(); const currentIdentity = getIdentity(); const currentDateTime = new Date(); @@ -264,7 +257,6 @@ export const createFilesCrud = ( savedBy: utilsGetIdentity(currentIdentity)!, tenant, - locale, webinyVersion: WEBINY_VERSION }; }); @@ -300,7 +292,6 @@ export const createFilesCrud = ( const where: FileManagerFilesStorageOperationsListParamsWhere = { ...{ meta: { private_not: true }, ...initialWhere }, - locale: getLocaleCode(), tenant: getTenantId() }; @@ -341,8 +332,7 @@ export const createFilesCrud = ( const where: FileManagerFilesStorageOperationsTagsParamsWhere = { ...initialWhere, - tenant: getTenantId(), - locale: getLocaleCode() + tenant: getTenantId() }; const params = { diff --git a/packages/api-file-manager/src/createFileManager/types.ts b/packages/api-file-manager/src/createFileManager/types.ts index c874a7d9b1f..0dd139dd1d9 100644 --- a/packages/api-file-manager/src/createFileManager/types.ts +++ b/packages/api-file-manager/src/createFileManager/types.ts @@ -9,7 +9,6 @@ export interface FileManagerConfig { filesPermissions: FilesPermissions; settingsPermissions: SettingsPermissions; getTenantId: () => string; - getLocaleCode: () => string; getIdentity: () => SecurityIdentity; getPermissions: GetPermissions; storage: FileStorage; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts index 111c31b8444..552f12f9031 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/Asset.ts @@ -5,7 +5,6 @@ type Setter = (arg: T | undefined) => T; export interface AssetData { id: string; tenant: string; - locale: string; key: string; size: number; contentType: string; @@ -37,9 +36,6 @@ export class Asset { getTenant() { return this.props.tenant; } - getLocale() { - return this.props.locale; - } getKey() { return this.props.key; } diff --git a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts index 4db5bc682cb..e61c225f4b6 100644 --- a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts +++ b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts @@ -68,7 +68,7 @@ export const setupAssetDelivery = (params: AssetDeliveryParams) => { let resolvedRequest: AssetRequest | undefined; let resolvedAsset: Asset | undefined; - // Create a `HandlerOnRequest` plugin to resolve `tenant` and `locale`, and allow the system to bootstrap. + // Create a `HandlerOnRequest` plugin to resolve `tenant`, and allow the system to bootstrap. const handlerOnRequest = createHandlerOnRequest(async (request, reply) => { const requestResolver = configBuilder.getAssetRequestResolver(); resolvedRequest = await requestResolver.resolve(request); @@ -97,12 +97,9 @@ export const setupAssetDelivery = (params: AssetDeliveryParams) => { return false; } - const assetLocale = resolvedAsset.getLocale(); - request.headers = { ...request.headers, - "x-tenant": resolvedAsset.getTenant(), - "x-i18n-locale": `default:${assetLocale};content:${assetLocale};` + "x-tenant": resolvedAsset.getTenant() }; return; diff --git a/packages/api-file-manager/src/types.ts b/packages/api-file-manager/src/types.ts index 256c0f4f01b..143b8fd79c0 100644 --- a/packages/api-file-manager/src/types.ts +++ b/packages/api-file-manager/src/types.ts @@ -261,7 +261,6 @@ export interface FileManagerFilesStorageOperationsGetParams { where: { id: string; tenant: string; - locale: string; }; } /** @@ -340,7 +339,6 @@ export interface FileManagerFilesStorageOperationsTagsResponse { } export interface FileManagerFilesStorageOperationsTagsParamsWhere extends FilesCrudListTagsWhere { - locale: string; tenant: string; } /** diff --git a/packages/api-file-manager/src/types/file.ts b/packages/api-file-manager/src/types/file.ts index 393105c2006..9bed9b9bd6b 100644 --- a/packages/api-file-manager/src/types/file.ts +++ b/packages/api-file-manager/src/types/file.ts @@ -32,7 +32,6 @@ export interface File { * Added with new storage operations refactoring. */ tenant: string; - locale: string; webinyVersion: string; /** * User can add new fields to the File object, so we must allow it in the types. @@ -43,7 +42,6 @@ export interface File { export interface FileAlias { tenant: string; - locale: string; fileId: string; alias: string; } diff --git a/packages/api-headless-cms-import-export/__tests__/crud/useCases/validateImportFromUrl.test.ts b/packages/api-headless-cms-import-export/__tests__/crud/useCases/validateImportFromUrl.test.ts index bbdceb542ce..d67cea52adf 100644 --- a/packages/api-headless-cms-import-export/__tests__/crud/useCases/validateImportFromUrl.test.ts +++ b/packages/api-headless-cms-import-export/__tests__/crud/useCases/validateImportFromUrl.test.ts @@ -5,6 +5,7 @@ import type { Context } from "~/types"; import { useHandler } from "~tests/helpers/useHandler"; import { NotFoundError } from "@webiny/handler-graphql"; import type { CmsModel } from "@webiny/api-headless-cms/types"; +import { ModelToAstConverter } from "@webiny/api-headless-cms/features/contentModel/ModelToAstConverter/index.js"; describe("validateImportFromUrl", () => { const { createContext } = useHandler(); @@ -12,9 +13,7 @@ describe("validateImportFromUrl", () => { const getModel = (modelId: string) => { return context.cms.getModel(modelId); }; - const getModelToAstConverter = () => { - return context.cms.getModelToAstConverter(); - }; + beforeEach(async () => { context = await createContext(); }); @@ -22,7 +21,7 @@ describe("validateImportFromUrl", () => { it("should fail on invalid data", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel }); @@ -40,7 +39,7 @@ describe("validateImportFromUrl", () => { it("should fail on no files found", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel }); @@ -59,7 +58,7 @@ describe("validateImportFromUrl", () => { it("should fail on invalid file", async () => { expect.assertions(2); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel }); try { @@ -105,7 +104,7 @@ describe("validateImportFromUrl", () => { it("should fail if no entries file", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel }); @@ -132,7 +131,7 @@ describe("validateImportFromUrl", () => { it("should fail if model not found", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel: () => { throw new NotFoundError("Model not found."); } @@ -161,7 +160,7 @@ describe("validateImportFromUrl", () => { it("should fail if model getter fails for some reason", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel: () => { throw new Error("Unspecified."); } @@ -190,7 +189,7 @@ describe("validateImportFromUrl", () => { it("should fail to match models - database model missing fields", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel: async () => { return { ...categoryModel, @@ -222,7 +221,7 @@ describe("validateImportFromUrl", () => { it("should fail to match models - exported model missing fields", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel: async () => { return { ...categoryModel @@ -258,7 +257,7 @@ describe("validateImportFromUrl", () => { it("should validate files properly", async () => { expect.assertions(1); const useCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel }); diff --git a/packages/api-headless-cms-import-export/src/crud/index.ts b/packages/api-headless-cms-import-export/src/crud/index.ts index a9f4da216aa..13db19bf126 100644 --- a/packages/api-headless-cms-import-export/src/crud/index.ts +++ b/packages/api-headless-cms-import-export/src/crud/index.ts @@ -32,6 +32,7 @@ import { UrlSigner } from "~/tasks/utils/urlSigner/index.js"; import { getBucket } from "~/tasks/utils/helpers/getBucket.js"; import { createS3Client } from "~/tasks/utils/helpers/s3Client.js"; import { AbortImportFromUrlUseCase } from "./useCases/abortImportFromUrl/index.js"; +import { ModelToAstConverter } from "@webiny/api-headless-cms/features/contentModel/ModelToAstConverter/index.js"; export const createHeadlessCmsImportExportCrud = async ( context: Context @@ -51,7 +52,7 @@ export const createHeadlessCmsImportExportCrud = async ( }); const validateImportFromUrlUseCase = new ValidateImportFromUrlUseCase({ - getModelToAstConverter: context.cms.getModelToAstConverter, + modelToAstConverter: context.container.resolve(ModelToAstConverter), getModel: context.cms.getModel }); const validateImportFromUrlIntegrityUseCase = new ValidateImportFromUrlIntegrityUseCase({ diff --git a/packages/api-headless-cms-import-export/src/crud/useCases/validateImportFromUrl/ValidateImportFromUrlUseCase.ts b/packages/api-headless-cms-import-export/src/crud/useCases/validateImportFromUrl/ValidateImportFromUrlUseCase.ts index 10d8b88e4bf..de80ada7e0e 100644 --- a/packages/api-headless-cms-import-export/src/crud/useCases/validateImportFromUrl/ValidateImportFromUrlUseCase.ts +++ b/packages/api-headless-cms-import-export/src/crud/useCases/validateImportFromUrl/ValidateImportFromUrlUseCase.ts @@ -11,19 +11,20 @@ import { getImportExportFileType } from "~/tasks/utils/helpers/getImportExportFi import { parseImportUrlData } from "~/crud/utils/parseImportUrlData.js"; import type { CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; import { makeSureModelsAreIdentical } from "~/crud/utils/makeSureModelsAreIdentical.js"; +import { ModelToAstConverter } from "@webiny/api-headless-cms/features/contentModel/ModelToAstConverter/index.js"; export interface IValidateImportFromUrlUseCaseParams { getModel: HeadlessCms["getModel"]; - getModelToAstConverter: HeadlessCms["getModelToAstConverter"]; + modelToAstConverter: ModelToAstConverter.Interface; } export class ValidateImportFromUrlUseCase implements IValidateImportFromUrlUseCase { private readonly getModel: HeadlessCms["getModel"]; - private readonly getModelToAstConverter: HeadlessCms["getModelToAstConverter"]; + private readonly modelToAstConverter: ModelToAstConverter.Interface; public constructor(params: IValidateImportFromUrlUseCaseParams) { this.getModel = params.getModel; - this.getModelToAstConverter = params.getModelToAstConverter; + this.modelToAstConverter = params.modelToAstConverter; } public async execute( @@ -65,7 +66,7 @@ export class ValidateImportFromUrlUseCase implements IValidateImportFromUrlUseCa } makeSureModelsAreIdentical({ - getModelToAstConverter: this.getModelToAstConverter, + modelToAstConverter: this.modelToAstConverter, model, target: validatedModel }); diff --git a/packages/api-headless-cms-import-export/src/crud/utils/makeSureModelsAreIdentical.ts b/packages/api-headless-cms-import-export/src/crud/utils/makeSureModelsAreIdentical.ts index 0e04f2d5b6e..c88ca402465 100644 --- a/packages/api-headless-cms-import-export/src/crud/utils/makeSureModelsAreIdentical.ts +++ b/packages/api-headless-cms-import-export/src/crud/utils/makeSureModelsAreIdentical.ts @@ -1,15 +1,11 @@ -import type { - CmsModel, - CmsModelAst, - CmsModelField, - HeadlessCms -} from "@webiny/api-headless-cms/types/index.js"; +import type { CmsModel, CmsModelAst, CmsModelField } from "@webiny/api-headless-cms/types/index.js"; import type { IExportedCmsModel } from "~/tasks/domain/abstractions/ExportContentEntriesController.js"; import { ModelFieldTraverser } from "@webiny/api-headless-cms/utils/index.js"; import { WebinyError } from "@webiny/error"; +import { ModelToAstConverter } from "@webiny/api-headless-cms/features/contentModel/ModelToAstConverter/index.js"; export interface IMakeSureModelsAreIdenticalParams { - getModelToAstConverter: HeadlessCms["getModelToAstConverter"]; + modelToAstConverter: ModelToAstConverter.Interface; model: CmsModel; target: IExportedCmsModel; } @@ -45,12 +41,10 @@ const getModelValues = (ast: CmsModelAst): IResult[] => { }; export const makeSureModelsAreIdentical = (params: IMakeSureModelsAreIdenticalParams): void => { - const { getModelToAstConverter, model, target } = params; + const { modelToAstConverter, model, target } = params; - const converter = getModelToAstConverter(); - - const modelAst = converter.toAst(model); - const targetAst = converter.toAst(target); + const modelAst = modelToAstConverter.toAst(model); + const targetAst = modelToAstConverter.toAst(target as CmsModel); const modelValues = getModelValues(modelAst); const targetValues = getModelValues(targetAst); diff --git a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts index 3df407126de..dc9ff7fdca3 100644 --- a/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts +++ b/packages/api-headless-cms/src/features/contentModel/ContentModelFeature.ts @@ -2,7 +2,6 @@ import { createFeature } from "@webiny/feature/api"; import { createMemoryCache } from "~/utils/index.js"; import { PluginModelsProvider } from "~/features/contentModel/shared/PluginModelsProvider.js"; import { ModelCache } from "~/features/contentModel/shared/abstractions.js"; -import { ModelToAstConverter } from "~/features/contentModel/shared/ModelToAstConverter.js"; import { ModelsFetcher } from "~/features/contentModel/shared/ModelsFetcher.js"; import { CreateModelFeature } from "~/features/contentModel/CreateModel/feature.js"; import { CreateModelFromFeature } from "~/features/contentModel/CreateModelFrom/feature.js"; @@ -11,15 +10,17 @@ import { DeleteModelFeature } from "~/features/contentModel/DeleteModel/feature. import { InitializeModelFeature } from "~/features/contentModel/InitializeModel/feature.js"; import { GetModelFeature } from "~/features/contentModel/GetModel/feature.js"; import { ListModelsFeature } from "~/features/contentModel/ListModels/feature.js"; +import { ModelToAstConverterFeature } from "~/features/contentModel/ModelToAstConverter/feature.js"; export const ContentModelFeature = createFeature({ name: "ContentModel", register(container) { container.registerInstance(ModelCache, createMemoryCache()); container.register(PluginModelsProvider).inSingletonScope(); - container.register(ModelToAstConverter); container.register(ModelsFetcher).inSingletonScope(); + ModelToAstConverterFeature.register(container); + // Query features GetModelFeature.register(container); ListModelsFeature.register(container); diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/ModelToAstConverter.ts similarity index 96% rename from packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts rename to packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/ModelToAstConverter.ts index 84dca0b1667..9c9215ea38b 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/ModelToAstConverter.ts +++ b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/ModelToAstConverter.ts @@ -15,7 +15,7 @@ import { PluginsContainer } from "~/legacy/abstractions.js"; class ModelToAstConverterImpl implements ConverterAbstraction.Interface { constructor(private pluginsContainer: PluginsContainer.Interface) {} - toAST(model: CmsModel): CmsModelAst { + toAst(model: CmsModel): CmsModelAst { const fieldTypePlugins = this.pluginsContainer.byType( "cms-model-field-to-graphql" ); diff --git a/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts new file mode 100644 index 00000000000..6c4c411d237 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts @@ -0,0 +1,15 @@ +import type { CmsModel, CmsModelAst } from "~/types/index.js"; +import { createAbstraction } from "@webiny/feature/createAbstraction.js"; + +/** + * Convert model to AST + */ +export interface IModelToAstConverter { + toAst(model: CmsModel): CmsModelAst; +} + +export const ModelToAstConverter = createAbstraction("ModelToAstConverter"); + +export namespace ModelToAstConverter { + export type Interface = IModelToAstConverter; +} diff --git a/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/feature.ts b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/feature.ts new file mode 100644 index 00000000000..13950c66b80 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { ModelToAstConverter } from "./ModelToAstConverter.js"; + +export const ModelToAstConverterFeature = createFeature({ + name: "ModelToAstConverter", + register(container) { + container.register(ModelToAstConverter); + } +}); diff --git a/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/index.ts b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/index.ts new file mode 100644 index 00000000000..ea9ebfb8417 --- /dev/null +++ b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/index.ts @@ -0,0 +1 @@ +export * from "./abstractions.js"; diff --git a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts index 1c92a247db2..c57f25e1321 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/abstractions.ts @@ -1,5 +1,5 @@ import { createAbstraction, Result } from "@webiny/feature/api"; -import type { CmsModel, CmsModelAst } from "~/types/index.js"; +import type { CmsModel } from "~/types/index.js"; import type { ICache } from "~/utils/caching/types.js"; import { ModelNotFoundError, ModelPersistenceError } from "~/domain/contentModel/errors.js"; @@ -23,19 +23,6 @@ export namespace ModelCache { export type Interface = ICache>; } -/** - * Convert model to AST - */ -export interface IModelToAstConverter { - toAST(model: CmsModel): CmsModelAst; -} - -export const ModelToAstConverter = createAbstraction("ModelToAstConverter"); - -export namespace ModelToAstConverter { - export type Interface = IModelToAstConverter; -} - /** * ModelsFetcher - Centralized model fetching with caching. * diff --git a/packages/api-headless-cms/src/index.ts b/packages/api-headless-cms/src/index.ts index 50376896fa5..3df002a4fb0 100644 --- a/packages/api-headless-cms/src/index.ts +++ b/packages/api-headless-cms/src/index.ts @@ -1,6 +1,5 @@ import type { CreateGraphQLParams } from "~/graphql/index.js"; import { createGraphQL as baseCreateGraphQL } from "~/graphql/index.js"; -import { createDefaultModelManager } from "~/modelManager/index.js"; import { createGraphQLFields } from "~/graphqlFields/index.js"; import { createValidators } from "~/validators/index.js"; import { @@ -52,7 +51,6 @@ export const createHeadlessCmsContext = (params: ContentContextParams) => { * Context for all Lambdas - everything is loaded now. */ createContextPlugin(params), - createDefaultModelManager(), createGraphQLFields(), createFieldConverters(), createValidators(), diff --git a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts deleted file mode 100644 index d171708f4b5..00000000000 --- a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { - CmsContext, - CmsDeleteEntryOptions, - CmsEntryListParams, - CmsModel, - CmsModelManager, - CreateCmsEntryInput, - CreateCmsEntryOptionsInput, - UpdateCmsEntryInput, - UpdateCmsEntryOptionsInput -} from "~/types/index.js"; -import { parseIdentifier } from "@webiny/utils"; - -export class DefaultCmsModelManager implements CmsModelManager { - private readonly _context: CmsContext; - public readonly model: CmsModel; - - public constructor(context: CmsContext, model: CmsModel) { - this._context = context; - this.model = model; - } - - public async create(data: CreateCmsEntryInput, options?: CreateCmsEntryOptionsInput) { - return this._context.cms.createEntry(this.model, data, options); - } - - public async delete(id: string, options?: CmsDeleteEntryOptions) { - const { version } = parseIdentifier(id); - if (version) { - return this._context.cms.deleteEntryRevision(this.model, id); - } - - return this._context.cms.deleteEntry(this.model, id, options); - } - - public async get(id: string) { - return this._context.cms.getEntryById(this.model, id); - } - - public async listPublished(params: CmsEntryListParams) { - return this._context.cms.listPublishedEntries(this.model, params); - } - - public async listLatest(params: CmsEntryListParams) { - return this._context.cms.listLatestEntries(this.model, params); - } - - public async listDeleted(params: CmsEntryListParams) { - return this._context.cms.listDeletedEntries(this.model, params); - } - - public async getPublishedByIds(ids: string[]) { - return this._context.cms.getPublishedEntriesByIds(this.model, ids); - } - - public async getLatestByIds(ids: string[]) { - return this._context.cms.getLatestEntriesByIds(this.model, ids); - } - - public async update( - id: string, - data: UpdateCmsEntryInput, - options?: UpdateCmsEntryOptionsInput - ) { - return this._context.cms.updateEntry(this.model, id, data, options); - } -} diff --git a/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts b/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts deleted file mode 100644 index 22e38f35fba..00000000000 --- a/packages/api-headless-cms/src/modelManager/SingletonModelManager.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { - CmsEntry, - CmsEntryValues, - CmsModelManager, - UpdateCmsEntryInput, - UpdateCmsEntryOptionsInput -} from "~/types/index.js"; -import { WebinyError } from "@webiny/error"; -import { CMS_MODEL_SINGLETON_TAG } from "~/constants.js"; -import { createCacheKey } from "@webiny/utils"; - -export interface ISingletonModelManager { - update(data: UpdateCmsEntryInput, options?: UpdateCmsEntryOptionsInput): Promise>; - get(): Promise>; -} - -export class SingletonModelManager implements ISingletonModelManager { - private readonly manager: CmsModelManager; - - private constructor(manager: CmsModelManager) { - if (!manager.model.tags?.includes(CMS_MODEL_SINGLETON_TAG)) { - throw new WebinyError({ - message: "Model is not marked as singular.", - code: "MODEL_NOT_MARKED_AS_SINGULAR", - data: { - model: manager.model - } - }); - } - this.manager = manager; - } - - public async update( - data: UpdateCmsEntryInput, - options?: UpdateCmsEntryOptionsInput - ): Promise> { - const entry = await this.get(); - - return await this.manager.update(entry.id, data, options); - } - - public async get(): Promise> { - const id = createCacheKey(this.manager.model.modelId); - try { - return await this.manager.get(`${id}#0001`); - } catch { - return await this.manager.create( - { - id - }, - { - skipValidators: ["required"] - } - ); - } - } - - public static create( - manager: CmsModelManager - ): ISingletonModelManager { - return new SingletonModelManager(manager); - } -} diff --git a/packages/api-headless-cms/src/modelManager/index.ts b/packages/api-headless-cms/src/modelManager/index.ts deleted file mode 100644 index 56b6eee161e..00000000000 --- a/packages/api-headless-cms/src/modelManager/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { CmsModelManager, ModelManagerPlugin } from "~/types/index.js"; -import { DefaultCmsModelManager } from "./DefaultCmsModelManager.js"; -export * from "./SingletonModelManager.js"; - -const plugin: ModelManagerPlugin = { - type: "cms-content-model-manager", - name: "content-model-manager-default", - async create(context, model) { - return new DefaultCmsModelManager(context, model) as CmsModelManager; - } -}; - -export const createDefaultModelManager = () => plugin; From 371151bdf57940e5abd4feb29165bf02d58588bc Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 10:48:58 +0100 Subject: [PATCH 52/71] wip: migration plan --- packages/api-file-manager/MIGRATION_PLAN.md | 877 ++++++++++++++++++++ 1 file changed, 877 insertions(+) create mode 100644 packages/api-file-manager/MIGRATION_PLAN.md diff --git a/packages/api-file-manager/MIGRATION_PLAN.md b/packages/api-file-manager/MIGRATION_PLAN.md new file mode 100644 index 00000000000..a20e3e9af92 --- /dev/null +++ b/packages/api-file-manager/MIGRATION_PLAN.md @@ -0,0 +1,877 @@ +# Migration Plan: api-file-manager → Feature-Based Architecture + +## Overview + +Migrate `packages/api-file-manager` from the current CRUD factory pattern to Clean Architecture with feature-based organization, following the pattern established in `packages/api-record-locking`. + +**Reference Implementation:** `packages/api-record-locking/src/features/` + +--- + +## Current Architecture Issues + +1. **CRUD Factory Pattern**: Uses `createFileManager()` factory that returns methods directly +2. **No Abstractions**: Operations not exposed through DI abstractions +3. **PubSub Topics**: Uses `@webiny/pubsub` topics instead of EventPublisher pattern +4. **Direct CMS Access**: Uses `CmsFilesStorage` which directly wraps `context.cms` instead of injecting CMS use cases +5. **Mixed Concerns**: Business logic, persistence, and event handling mixed together in `CmsFilesStorage` +6. **No Domain Layer**: File entity is just an interface, no domain behavior +7. **God Object Storage**: `CmsFilesStorage` handles all operations instead of feature-specific repositories + +--- + +## Target Architecture + +``` +packages/api-file-manager/src/ +├── domain/ +│ ├── file/ +│ │ ├── abstractions.ts # FileModel abstraction +│ │ ├── errors.ts # File domain errors +│ │ ├── types.ts # File domain types +│ │ └── File.ts # File domain model (if needed) +│ └── settings/ +│ ├── abstractions.ts # Settings abstractions +│ ├── errors.ts # Settings domain errors +│ └── types.ts # Settings domain types +├── features/ +│ ├── file/ +│ │ ├── GetFile/ +│ │ ├── ListFiles/ +│ │ ├── ListTags/ +│ │ ├── CreateFile/ +│ │ ├── CreateFilesInBatch/ +│ │ ├── UpdateFile/ +│ │ └── DeleteFile/ +│ └── settings/ +│ ├── GetSettings/ +│ └── UpdateSettings/ +└── graphql/ + └── schema.ts # Updated to use use cases via DI +``` + +--- + +## Domain Layer + +### File Domain (`domain/file/`) + +#### Model Definition (`domain/file/fileModel.ts`) + +The CMS model definition for the `fmFile` model, moved from `src/cmsFileStorage/file.model.ts`. + +```typescript +export const FILE_MODEL_ID = "fmFile"; +export const createFileModel = (params: { withPrivateFiles: boolean }) => { ... } +``` + +This model defines the schema for file storage in CMS with fields: location, name, key, type, size, meta, tags, aliases, and optionally accessControl. + +#### Errors (`domain/file/errors.ts`) + +```typescript +FileNotFoundError // File not found by ID +FileListError // Error listing files +FileCreateError // Error creating file +FileUpdateError // Error updating file +FileDeleteError // Error deleting file +FileAlreadyExistsError // File with key already exists +InvalidFileSizeError // File size outside allowed range +InvalidFileTypeError // File type not allowed +``` + +#### Abstractions (`domain/file/abstractions.ts`) + +```typescript +FileModel // The fmFile CMS model abstraction (registered via container.registerInstance) +``` + +This abstraction will be registered in the composite feature like: +```typescript +container.registerInstance(FileModel, params.model); +``` + +#### Types (`domain/file/types.ts`) + +```typescript +File // File entity +FileInput // File input for creation +FileAlias // File alias +CreatedBy // Creator identity +FileAccess // Access control (public/private) +``` + +### Settings Domain (`domain/settings/`) + +#### Errors (`domain/settings/errors.ts`) + +```typescript +SettingsNotFoundError // Settings not found +SettingsUpdateError // Error updating settings +``` + +#### Abstractions (`domain/settings/abstractions.ts`) + +```typescript +FileManagerConfig { // Settings configuration + uploadMinFileSize: number; + uploadMaxFileSize: number; + srcPrefix: string; +} +``` + +#### Types (`domain/settings/types.ts`) + +```typescript +FileManagerSettings // Settings entity +``` + +--- + +## Feature Dependencies (Implementation Order) + +### Level 0: No Internal Dependencies +These features only depend on external systems (CMS, storage) and can be implemented first. + +1. **GetSettings** - Read settings from CMS +2. **GetFile** - Read single file from CMS + +### Level 1: Depends on Level 0 +3. **ListFiles** - List files (may depend on GetSettings for validation) +4. **ListTags** - List unique tags from files +5. **UpdateSettings** - Update settings (depends on GetSettings) + +### Level 2: Depends on Level 0-1 +6. **CreateFile** - Create file (depends on GetSettings for validation) +7. **UpdateFile** - Update file (depends on GetFile) +8. **DeleteFile** - Delete file (depends on GetFile) + +### Level 3: Depends on Level 2 +9. **CreateFilesInBatch** - Batch create (depends on CreateFile logic) + +### Composite +10. **FileManagerFeature** - Composite feature that registers all file and settings sub-features + +--- + +## Feature Details + +### 1. GetSettings Feature (`features/settings/GetSettings/`) + +**Dependencies:** +- `GetSettings` (from `@webiny/api-core/features/settings/GetSettings`) +- `IdentityContext` +- `TenantContext` + +**Repository:** +- Uses `GetSettings` from `@webiny/api-core` directly (no custom repository needed) + +**Use Case:** +```typescript +export interface IGetSettingsUseCase { + execute(): Promise>; +} +``` + +**Implementation:** +- Calls `getSettings.execute({ name: "file-manager" })` +- Returns settings or null if not found +- No events (query operation) + +**Errors:** +- Returns null on not found (no error) + +**Files:** +- `abstractions.ts` - Use case interface +- `GetSettingsUseCase.ts` - Implementation +- `feature.ts` - Feature registration + +--- + +### 2. GetFile Feature (`features/file/GetFile/`) + +**Dependencies:** +- `GetEntryByIdUseCase` (from `@webiny/api-headless-cms/features/contentEntry/GetEntryById`) +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) + +**Repository:** +- `GetFileRepository` - wraps GetEntryByIdUseCase with FileModel injection + +```typescript +export interface IGetFileRepository { + getById(id: string): Promise | null>; +} + +class GetFileRepositoryImpl implements IGetFileRepository { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private fileModel: CmsModel, + private identityContext: IdentityContext.Interface + ) {} + + async getById(id: string) { + return await this.identityContext.withoutAuthorization(async () => { + const result = await this.getEntryById.execute(this.fileModel, id); + return result.isOk() ? result.value : null; + }); + } +} +``` + +**Use Case:** +```typescript +export interface IGetFileUseCase { + execute(input: { id: string }): Promise>; +} +``` + +**Implementation:** +- Calls repository.getById(id) +- Validates file permissions +- Converts CmsEntry to File domain object +- Returns File or FileNotFoundError + +**Errors:** +- `FileNotFoundError` (from `domain/file/errors.ts`) + +**Files:** +- `abstractions.ts` - Use case and repository interfaces +- `GetFileRepository.ts` - Repository implementation +- `GetFileUseCase.ts` - Use case implementation +- `feature.ts` - Feature registration + +--- + +### 3. ListFiles Feature (`features/file/ListFiles/`) + +**Dependencies:** +- `ListLatestEntriesUseCase` (from CMS) +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) +- `GetSettings` (optional, for validation) + +**Repository:** +- `ListFilesRepository` - wraps ListLatestEntriesUseCase +- Handles where clause processing + +**Use Case:** +- Applies permission filtering +- Supports search, pagination, sorting + +**Errors:** +- `FileListError` (from `domain/file/errors.ts`) + +**Input:** +```typescript +{ + search?: string; + where?: Record; + limit?: number; + after?: string; + sort?: string[]; +} +``` + +**Output:** +```typescript +{ + items: File[]; + meta: CmsEntryMeta; +} +``` + +--- + +### 4. ListTags Feature (`features/file/ListTags/`) + +**Dependencies:** +- `GetUniqueFieldValuesUseCase` (from CMS) +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) + +**Repository:** +- `ListTagsRepository` - wraps GetUniqueFieldValuesUseCase + +**Use Case:** +- Returns unique tag values with counts + +**Errors:** +- `FileListError` (from `domain/file/errors.ts`) + +**Input:** +```typescript +{ + where?: Record; +} +``` + +**Output:** +```typescript +{ + tag: string; + count: number; +}[] +``` + +--- + +### 5. UpdateSettings Feature (`features/settings/UpdateSettings/`) + +**Dependencies:** +- `UpdateSettings` (from `@webiny/api-core/features/settings/UpdateSettings`) +- `GetSettings` use case +- `IdentityContext` +- `TenantContext` +- `EventPublisher` + +**Repository:** +- Uses `UpdateSettings` from `@webiny/api-core` directly (no custom repository needed) + +**Use Case:** +```typescript +export interface IUpdateSettingsUseCase { + execute(input: UpdateSettingsInput): Promise>; +} +``` + +**Implementation:** +- Validates settings input +- Calls `updateSettings.execute({ name: "file-manager", data: input })` +- Creates if not exists, updates if exists (handled by core UpdateSettings) +- Wraps with event decorator + +**Event Decorator:** +- `UpdateSettingsEventsDecorator` - decorates use case with events +- Publishes events before/after/on-error + +**Events:** +- `SettingsBeforeUpdateEvent` +- `SettingsAfterUpdateEvent` +- `SettingsUpdateErrorEvent` + +**Errors:** +- `SettingsUpdateError` (from `domain/settings/errors.ts`) + +**Input:** +```typescript +{ + uploadMinFileSize?: number; + uploadMaxFileSize?: number; + srcPrefix?: string; +} +``` + +**Files:** +- `abstractions.ts` - Use case interface, error interfaces +- `UpdateSettingsUseCase.ts` - Use case implementation +- `UpdateSettingsEventsDecorator.ts` - Events decorator +- `feature.ts` - Feature registration with decorator + +--- + +### 6. CreateFile Feature (`features/file/CreateFile/`) + +**Dependencies:** +- `CreateEntryUseCase` (from `@webiny/api-headless-cms/features/contentEntry/CreateEntry`) +- `GetSettingsUseCase` (from `features/settings/GetSettings`) +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) +- `EventPublisher` + +**Repository:** +- `CreateFileRepository` - wraps CreateEntryUseCase with FileModel injection + +```typescript +export interface ICreateFileRepository { + create(data: FileInput): Promise>; +} + +class CreateFileRepositoryImpl implements ICreateFileRepository { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private fileModel: CmsModel, + private identityContext: IdentityContext.Interface + ) {} + + async create(data: FileInput) { + return await this.identityContext.withoutAuthorization(async () => { + const result = await this.createEntry.execute(this.fileModel, data); + if (result.isFail()) { + throw result.error; + } + return result.value; + }); + } +} +``` + +**Use Case:** +```typescript +export interface ICreateFileUseCase { + execute(input: CreateFileInput): Promise>; +} +``` + +**Implementation:** +- Gets settings to validate file size +- Validates file size against settings (uploadMinFileSize, uploadMaxFileSize) +- Validates required fields +- Sets default values (tags, aliases) +- Calls repository.create(data) +- Converts CmsEntry to File domain object +- Wraps with event decorator + +**Event Decorator:** +- `CreateFileEventsDecorator` - decorates use case with events +- Publishes events before/after/on-error + +**Events:** +- `FileBeforeCreateEvent` +- `FileAfterCreateEvent` +- `FileCreateErrorEvent` + +**Errors:** +- `FileCreateError` (from `domain/file/errors.ts`) +- `InvalidFileSizeError` (from `domain/file/errors.ts`) +- `FileAlreadyExistsError` (from `domain/file/errors.ts`) + +**Input:** +```typescript +{ + key: string; + size: number; + type: string; + name: string; + meta?: Record; + tags?: string[]; + location?: { folderId: string }; + aliases?: string[]; +} +``` + +**Files:** +- `abstractions.ts` - Use case, repository, and error interfaces +- `CreateFileRepository.ts` - Repository implementation +- `CreateFileUseCase.ts` - Use case implementation +- `CreateFileEventsDecorator.ts` - Events decorator +- `feature.ts` - Feature registration with decorator + +--- + +### 7. UpdateFile Feature (`features/file/UpdateFile/`) + +**Dependencies:** +- `UpdateEntryUseCase` (from CMS) +- `GetFile` use case +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) +- `EventPublisher` + +**Repository:** +- `UpdateFileRepository` - wraps UpdateEntryUseCase + +**Use Case:** +- Validates file exists +- Validates permissions +- Merges with existing data +- Publishes events + +**Events:** +- `FileBeforeUpdateEvent` +- `FileAfterUpdateEvent` +- `FileUpdateErrorEvent` + +**Errors:** +- `FileNotFoundError` (from `domain/file/errors.ts`) +- `FileUpdateError` (from `domain/file/errors.ts`) + +**Input:** +```typescript +{ + name?: string; + meta?: Record; + tags?: string[]; + location?: { folderId: string }; + aliases?: string[]; +} +``` + +--- + +### 8. DeleteFile Feature (`features/file/DeleteFile/`) + +**Dependencies:** +- `DeleteEntryUseCase` (from CMS) +- `GetFile` use case +- `IdentityContext` +- `FileModel` (from `domain/file/abstractions.ts`) +- `EventPublisher` + +**Repository:** +- `DeleteFileRepository` - wraps DeleteEntryUseCase + +**Use Case:** +- Validates file exists +- Validates permissions +- Publishes events +- Returns deleted file info + +**Events:** +- `FileBeforeDeleteEvent` +- `FileAfterDeleteEvent` +- `FileDeleteErrorEvent` + +**Errors:** +- `FileNotFoundError` (from `domain/file/errors.ts`) +- `FileDeleteError` (from `domain/file/errors.ts`) + +--- + +### 9. CreateFilesInBatch Feature (`features/file/CreateFilesInBatch/`) + +**Dependencies:** +- `CreateFile` use case (reuses logic) +- `GetSettings` use case +- `IdentityContext` +- `EventPublisher` + +**Repository:** +- Reuses `CreateFileRepository` + +**Use Case:** +- Validates each file +- Creates files in batch +- Publishes batch events + +**Events:** +- `FileBeforeBatchCreateEvent` +- `FileAfterBatchCreateEvent` +- `FileBatchCreateErrorEvent` + +**Errors:** +- `FileCreateError` (from `domain/file/errors.ts`) + +**Input:** +```typescript +{ + files: FileInput[]; + meta?: Record; +} +``` + +--- + +### 10. FileManagerFeature Composite Feature (`features/FileManagerFeature.ts`) + +**Registers:** +- All sub-features in dependency order +- Domain abstractions: + - `FileModel` (from `domain/file/abstractions.ts`) + - `FileManagerConfig` (from `domain/settings/abstractions.ts`) + +**Configuration:** +```typescript +{ + model: CmsModel; # The fmFile model + config: { + uploadMinFileSize: number; + uploadMaxFileSize: number; + srcPrefix: string; + } +} +``` + +--- + +## Event Types + +All events follow EventPublisher pattern from `@webiny/api-core/features/EventPublisher`. + +### File Events + +```typescript +// Create +FileBeforeCreateEvent { file: File, meta?: Record } +FileAfterCreateEvent { file: File, meta?: Record } +FileCreateErrorEvent { input: FileInput, error: Error } + +// Batch Create +FileBeforeBatchCreateEvent { files: File[], meta?: Record } +FileAfterBatchCreateEvent { files: File[], meta?: Record } +FileBatchCreateErrorEvent { inputs: FileInput[], error: Error } + +// Update +FileBeforeUpdateEvent { original: File, file: File, input: Partial } +FileAfterUpdateEvent { original: File, file: File, input: Partial } +FileUpdateErrorEvent { id: string, input: Partial, error: Error } + +// Delete +FileBeforeDeleteEvent { file: File } +FileAfterDeleteEvent { file: File } +FileDeleteErrorEvent { id: string, error: Error } +``` + +### Settings Events + +```typescript +SettingsBeforeUpdateEvent { + original: FileManagerSettings | null, + settings: FileManagerSettings, + input: Partial +} + +SettingsAfterUpdateEvent { + original: FileManagerSettings | null, + settings: FileManagerSettings, + input: Partial +} + +SettingsUpdateErrorEvent { + input: Partial, + error: Error +} +``` + +--- + +## GraphQL Schema Migration + +**Current:** `packages/api-file-manager/src/graphql/filesSchema.ts` + +**Changes:** +- Replace `context.fileManager.*` calls with `context.container.resolve(UseCase)` +- Update resolvers to handle Result pattern +- Keep existing GraphQL schema structure (no breaking changes) + +**Pattern:** +```typescript +async getFile(_, { id }, context) { + await checkPermissions(context); + const useCase = context.container.resolve(GetFileUseCase); + const result = await useCase.execute({ id }); + if (result.isFail()) { + throw result.error; + } + return result.value; +} +``` + +--- + +## CMS Use Cases Required + +From `@webiny/api-headless-cms/features/contentEntry/`: + +- `GetEntryByIdUseCase` - Get single entry by ID +- `GetEntryUseCase` - Get single entry by query +- `ListLatestEntriesUseCase` - List entries +- `CreateEntryUseCase` - Create entry +- `UpdateEntryUseCase` - Update entry +- `DeleteEntryUseCase` - Delete entry +- `GetUniqueFieldValuesUseCase` - Get unique field values + +From `@webiny/api-headless-cms/features/contentModel/`: + +- `GetModelUseCase` - Get CMS model by ID + +--- + +## Migration Strategy + +### Phase 1: Domain Layer +1. Move `src/cmsFileStorage/file.model.ts` to `domain/file/fileModel.ts` +2. Create `domain/file/errors.ts` with all file error types (extend BaseError) +3. Create `domain/file/abstractions.ts` for FileModel abstraction +4. Create `domain/file/types.ts` for file domain types +5. Create `domain/settings/errors.ts` with all settings error types (extend BaseError) +6. Create `domain/settings/abstractions.ts` for FileManagerConfig abstraction +7. Create `domain/settings/types.ts` for settings domain types + +### Phase 2: Level 0 Features (2 features) +Each feature includes: abstractions, use case implementation, repository (if needed), events decorator (if needed), and feature registration. + +8. **GetSettings** (`features/settings/GetSettings/`) + - `abstractions.ts` - Interface + - `GetSettingsUseCase.ts` - Implementation + - `feature.ts` - Registration + +9. **GetFile** (`features/file/GetFile/`) + - `abstractions.ts` - Use case and repository interfaces + - `GetFileRepository.ts` - Repository wrapping GetEntryByIdUseCase + - `GetFileUseCase.ts` - Implementation + - `feature.ts` - Registration + +### Phase 3: Level 1 Features (3 features) +Each feature includes: abstractions, repository, use case, events decorator, and feature registration. + +10. **ListFiles** (`features/file/ListFiles/`) + - `abstractions.ts` - Interfaces + - `ListFilesRepository.ts` - Repository wrapping ListLatestEntriesUseCase + - `ListFilesUseCase.ts` - Implementation + - `feature.ts` - Registration + +11. **ListTags** (`features/file/ListTags/`) + - `abstractions.ts` - Interfaces + - `ListTagsRepository.ts` - Repository wrapping GetUniqueFieldValuesUseCase + - `ListTagsUseCase.ts` - Implementation + - `feature.ts` - Registration + +12. **UpdateSettings** (`features/settings/UpdateSettings/`) + - `abstractions.ts` - Interfaces with error types + - `UpdateSettingsUseCase.ts` - Implementation + - `UpdateSettingsEventsDecorator.ts` - Events decorator + - `feature.ts` - Registration with decorator + +### Phase 4: Level 2 Features (3 features) +Each feature includes: abstractions, repository, use case, events decorator, and feature registration. + +13. **CreateFile** (`features/file/CreateFile/`) + - `abstractions.ts` - Interfaces with error types + - `CreateFileRepository.ts` - Repository wrapping CreateEntryUseCase + - `CreateFileUseCase.ts` - Implementation with validation + - `CreateFileEventsDecorator.ts` - Events decorator + - `feature.ts` - Registration with decorator + +14. **UpdateFile** (`features/file/UpdateFile/`) + - `abstractions.ts` - Interfaces with error types + - `UpdateFileRepository.ts` - Repository wrapping UpdateEntryUseCase + - `UpdateFileUseCase.ts` - Implementation + - `UpdateFileEventsDecorator.ts` - Events decorator + - `feature.ts` - Registration with decorator + +15. **DeleteFile** (`features/file/DeleteFile/`) + - `abstractions.ts` - Interfaces with error types + - `DeleteFileRepository.ts` - Repository wrapping DeleteEntryUseCase + - `DeleteFileUseCase.ts` - Implementation + - `DeleteFileEventsDecorator.ts` - Events decorator + - `feature.ts` - Registration with decorator + +### Phase 5: Level 3 Features (1 feature) +16. **CreateFilesInBatch** (`features/file/CreateFilesInBatch/`) + - `abstractions.ts` - Interfaces with error types + - `CreateFilesInBatchUseCase.ts` - Implementation (reuses CreateFile logic) + - `CreateFilesInBatchEventsDecorator.ts` - Batch events decorator + - `feature.ts` - Registration with decorator + +### Phase 6: Integration +17. **FileManagerFeature** (`features/FileManagerFeature.ts`) + - Composite feature that registers all sub-features in dependency order + - Registers FileModel and FileManagerConfig via container.registerInstance + +18. Update GraphQL schema (`graphql/schema.ts`) + - Replace `context.fileManager.*` with `context.container.resolve(UseCase)` + - Handle Result pattern (check isFail(), throw error or return value) + +19. Update context setup + - Register FileManagerFeature in main plugin + - Pass CMS model and config to feature + - Remove old CRUD factory pattern + +--- + +## Out of Scope (Keep As-Is) + +These components will remain unchanged for now: + +1. **Physical Storage Layer** (`storage/FileStorage.ts`) + - Handles cloud storage (S3) uploads/deletes + - Can be migrated later if needed + +2. **CmsFilesStorage** (`cmsFileStorage/CmsFilesStorage.ts`) + - **DO NOT MIGRATE** - This god object will be replaced by feature-specific repositories + - Each feature will have its own repository that wraps CMS use cases + - Repositories will receive FileModel and CMS use cases via DI + +3. **Asset Delivery** (`delivery/`) + - Separate concern from file management + - Already modular + +4. **Plugins System** (`plugins/`) + - Storage transform plugins + - Keep existing plugin system + +5. **Handlers** (`handlers/`) + - Lambda handlers for S3 events + - Separate deployment units + +6. **Enterprise Features** (`enterprise/`) + - Threat scanning wrapper + - Keep as-is + +7. **Model Modifier** (`modelModifier/`) + - CMS model field modifications + - Keep as-is + +--- + +## Testing Strategy + +Each feature should have: +1. **Unit tests** for use case logic +2. **Integration tests** for repository operations +3. **Event tests** for event publishing + +Test files should mirror feature structure: +``` +features/file/CreateFile/ +├── __tests__/ +│ ├── CreateFileUseCase.test.ts +│ ├── CreateFileRepository.test.ts +│ └── CreateFileEventsDecorator.test.ts +``` + +--- + +## Breaking Changes + +**None expected.** This is an internal refactor that maintains: +- Same GraphQL API +- Same context.fileManager interface (initially) +- Same event payloads (migrated to EventPublisher) + +**Future Deprecation:** +- `context.fileManager.*` methods (after features are stable) +- PubSub topics (replaced by EventPublisher events) + +--- + +## Estimated Effort + +- **Domain Layer** (7 files): 3-4 hours + - Move model file, create errors, abstractions, types for both subdomains +- **Level 0 Features** (2 features, 5 files): 4-6 hours + - GetSettings (3 files), GetFile (4 files with repository) +- **Level 1 Features** (3 features, 11 files): 8-12 hours + - ListFiles (4 files), ListTags (4 files), UpdateSettings (5 files with decorator) +- **Level 2 Features** (3 features, 15 files): 12-16 hours + - CreateFile (5 files), UpdateFile (5 files), DeleteFile (5 files) - all with decorators +- **Level 3 Features** (1 feature, 4 files): 4-5 hours + - CreateFilesInBatch (4 files with decorator) +- **Integration** (2 tasks): 6-8 hours + - FileManagerFeature composite, GraphQL schema migration, context setup +- **Testing**: 10-15 hours + - Unit tests for all use cases, repositories, and decorators + +**Total**: 47-66 hours (6-8 working days) + +**Note**: Each feature is a complete vertical slice with abstractions, repositories, use cases, events decorators, and feature registration. + +--- + +## Success Criteria + +1. ✅ All file operations use DI-injected use cases via `context.container.resolve()` +2. ✅ No direct `context.cms` usage in features - only injected CMS use cases +3. ✅ All lifecycle events migrated to EventPublisher pattern with decorators +4. ✅ GraphQL schema uses use cases via container (no more `context.fileManager.*`) +5. ✅ Proper Result pattern for error handling (all use cases return `Result`) +6. ✅ Repository layer hides CMS implementation details +7. ✅ One repository per feature (no god objects like CmsFilesStorage) +8. ✅ Events handled via decorators (separated from business logic) +9. ✅ All existing tests pass +10. ✅ No breaking changes to public API (GraphQL schema remains the same) +11. ✅ FileModel registered as abstraction via `container.registerInstance()` +12. ✅ Each feature is complete vertical slice (abstractions, repo, use case, events, registration) +13. ✅ Settings features use `@webiny/api-core` GetSettings/UpdateSettings +14. ✅ All errors extend BaseError from `@webiny/feature/api` From a5cb1c5acdf22d42275caa4319eb3a8d0a1ea4d8 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 18:04:09 +0100 Subject: [PATCH 53/71] wip: migrate file manager --- .../features/security/utils/AppPermissions.ts | 14 +- .../AliasesStorageOperations.ts | 8 +- packages/api-file-manager-ddb/src/index.ts | 29 +- .../src/operations/FilesStorageOperations.ts | 41 --- .../operations/SettingsStorageOperations.ts | 116 -------- .../src/plugins/SettingsAttributePlugin.ts | 11 - .../api-file-manager-ddb/src/plugins/index.ts | 1 - packages/api-file-manager/MIGRATION_PLAN.md | 40 ++- .../src/FileManagerContextSetup.ts | 48 ++-- .../src/cmsFileStorage/CmsFilesStorage.ts | 26 +- .../src/domain/file/abstractions.ts | 12 + .../src/domain/file/errors.ts | 109 ++++++++ .../file/fileModel.ts} | 30 ++- .../api-file-manager/src/domain/file/types.ts | 71 +++++ .../settings/constants.ts | 0 .../src/domain/settings/errors.ts | 21 ++ .../src/domain/settings/types.ts | 12 + .../src/enterprise/applyThreatScanning.ts | 1 + .../file/CreateFile/CreateFileRepository.ts | 34 +++ .../file/CreateFile/CreateFileUseCase.ts | 95 +++++++ .../features/file/CreateFile/abstractions.ts | 68 +++++ .../src/features/file/CreateFile/events.ts | 54 ++++ .../src/features/file/CreateFile/feature.ts | 11 + .../src/features/file/CreateFile/index.ts | 1 + .../file/FileUrlGenerator/abstractions.ts | 11 + .../file/GetFile/GetFileRepository.ts | 39 +++ .../features/file/GetFile/GetFileUseCase.ts | 45 ++++ .../src/features/file/GetFile/abstractions.ts | 55 ++++ .../src/features/file/GetFile/feature.ts | 11 + .../src/features/file/GetFile/index.ts | 1 + .../features/file/shared/EntryToFileMapper.ts | 28 ++ .../features/file/shared/FileToEntryMapper.ts | 31 +++ .../GetSettings/GetSettingsUseCase.ts | 29 ++ .../settings/GetSettings/abstractions.ts | 16 ++ .../features/settings/GetSettings/feature.ts | 9 + .../SettingsInstaller.ts | 2 +- .../{ => SettingsInstaller}/feature.ts | 0 .../UpdateSettingsEventsDecorator.ts | 97 +++++++ .../UpdateSettings/UpdateSettingsUseCase.ts | 46 ++++ .../settings/UpdateSettings/abstractions.ts | 24 ++ .../settings/UpdateSettings/feature.ts | 12 + .../src/features/shared/abstractions.ts | 19 ++ .../src/graphql/filesSchema.ts | 16 +- packages/api-file-manager/src/index.ts | 35 ++- .../src/storage/FileStorage.ts | 91 +------ packages/api-file-manager/src/types.ts | 253 +----------------- packages/api-headless-cms/src/types/types.ts | 14 - 47 files changed, 1066 insertions(+), 671 deletions(-) rename packages/api-file-manager-ddb/src/{operations => }/AliasesStorageOperations.ts (94%) delete mode 100644 packages/api-file-manager-ddb/src/operations/FilesStorageOperations.ts delete mode 100644 packages/api-file-manager-ddb/src/operations/SettingsStorageOperations.ts delete mode 100644 packages/api-file-manager-ddb/src/plugins/SettingsAttributePlugin.ts delete mode 100644 packages/api-file-manager-ddb/src/plugins/index.ts create mode 100644 packages/api-file-manager/src/domain/file/abstractions.ts create mode 100644 packages/api-file-manager/src/domain/file/errors.ts rename packages/api-file-manager/src/{cmsFileStorage/file.model.ts => domain/file/fileModel.ts} (86%) create mode 100644 packages/api-file-manager/src/domain/file/types.ts rename packages/api-file-manager/src/{features => domain}/settings/constants.ts (100%) create mode 100644 packages/api-file-manager/src/domain/settings/errors.ts create mode 100644 packages/api-file-manager/src/domain/settings/types.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/events.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/feature.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFile/index.ts create mode 100644 packages/api-file-manager/src/features/file/FileUrlGenerator/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts create mode 100644 packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/GetFile/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/GetFile/feature.ts create mode 100644 packages/api-file-manager/src/features/file/GetFile/index.ts create mode 100644 packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts create mode 100644 packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts create mode 100644 packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts create mode 100644 packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts create mode 100644 packages/api-file-manager/src/features/settings/GetSettings/feature.ts rename packages/api-file-manager/src/features/settings/{ => SettingsInstaller}/SettingsInstaller.ts (94%) rename packages/api-file-manager/src/features/settings/{ => SettingsInstaller}/feature.ts (100%) create mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts create mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts create mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts create mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts create mode 100644 packages/api-file-manager/src/features/shared/abstractions.ts diff --git a/packages/api-core/src/features/security/utils/AppPermissions.ts b/packages/api-core/src/features/security/utils/AppPermissions.ts index 67d310f8ace..a61562ec69c 100644 --- a/packages/api-core/src/features/security/utils/AppPermissions.ts +++ b/packages/api-core/src/features/security/utils/AppPermissions.ts @@ -15,10 +15,6 @@ export type EnsureParams = Partial<{ owns: CreatedBy; }>; -export type Options = Partial<{ - throw: boolean; -}>; - export class AppPermissions { getIdentity: () => SecurityIdentity | Promise; getPermissions: () => TPermission[] | Promise; @@ -37,7 +33,7 @@ export class AppPermissions { + async ensure(params: EnsureParams = {}): Promise { if (await this.hasFullAccess()) { return true; } @@ -54,10 +50,6 @@ export class AppPermissions; private readonly table: Table; diff --git a/packages/api-file-manager-ddb/src/index.ts b/packages/api-file-manager-ddb/src/index.ts index 591178d2bef..4bcbb750ba1 100644 --- a/packages/api-file-manager-ddb/src/index.ts +++ b/packages/api-file-manager-ddb/src/index.ts @@ -1,40 +1,17 @@ import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import ddbPlugins from "@webiny/db-dynamodb/plugins/index.js"; -import { PluginsContainer } from "@webiny/plugins"; import type { PluginCollection } from "@webiny/plugins/types.js"; import type { FileManagerStorageOperations } from "@webiny/api-file-manager/types.js"; -import { FilesStorageOperations } from "~/operations/FilesStorageOperations.js"; -import { SettingsStorageOperations } from "~/operations/SettingsStorageOperations.js"; -import { SettingsAttributePlugin } from "./plugins/index.js"; -import { AliasesStorageOperations } from "~/operations/AliasesStorageOperations.js"; -import { CompressorPlugin } from "@webiny/api"; +import { AliasesStorageOperations } from "./AliasesStorageOperations.js"; export interface StorageOperationsConfig { documentClient: DynamoDBDocument; plugins?: PluginCollection; } -export * from "./plugins/index.js"; - export const createFileManagerStorageOperations = ({ - documentClient, - plugins: userPlugins + documentClient }: StorageOperationsConfig): FileManagerStorageOperations => { - const plugins = new PluginsContainer([ - ddbPlugins(), - // User plugins - ...(userPlugins || []) - ]); - return { - beforeInit: async context => { - const types: string[] = [SettingsAttributePlugin.type, CompressorPlugin.type]; - for (const type of types) { - plugins.mergeByType(context.plugins, type); - } - }, - files: new FilesStorageOperations(), - aliases: new AliasesStorageOperations({ documentClient }), - settings: new SettingsStorageOperations({ documentClient }) + aliases: new AliasesStorageOperations({ documentClient }) }; }; diff --git a/packages/api-file-manager-ddb/src/operations/FilesStorageOperations.ts b/packages/api-file-manager-ddb/src/operations/FilesStorageOperations.ts deleted file mode 100644 index 7922640989b..00000000000 --- a/packages/api-file-manager-ddb/src/operations/FilesStorageOperations.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - File, - FileManagerFilesStorageOperations, - FileManagerFilesStorageOperationsListResponse, - FileManagerFilesStorageOperationsTagsResponse -} from "@webiny/api-file-manager/types.js"; - -/** - * This class is here to satisfy TS interface, but it will always be overridden by CMS storage operations - * within the `api-file-manager` package itself. This will remain here until we find a better approach to organizing - * storage operations, and connecting app logic to CMS. - */ -export class FilesStorageOperations implements FileManagerFilesStorageOperations { - create(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - createBatch(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - delete(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - get(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - list(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - tags(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } - - update(): Promise { - throw new Error("api-file-manager-ddb does not implement the Files storage operations."); - } -} diff --git a/packages/api-file-manager-ddb/src/operations/SettingsStorageOperations.ts b/packages/api-file-manager-ddb/src/operations/SettingsStorageOperations.ts deleted file mode 100644 index 65d6727d57e..00000000000 --- a/packages/api-file-manager-ddb/src/operations/SettingsStorageOperations.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { Entity } from "@webiny/db-dynamodb/toolbox.js"; -import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; -import type { - FileManagerSettings, - FileManagerSettingsStorageOperations, - FileManagerSettingsStorageOperationsCreateParams, - FileManagerSettingsStorageOperationsUpdateParams, - FileManagerStorageOperationsDeleteSettings, - FileManagerStorageOperationsGetSettingsParams -} from "@webiny/api-file-manager/types.js"; -import WebinyError from "@webiny/error"; -import { createStandardEntity, createTable, deleteItem, get, put } from "@webiny/db-dynamodb"; - -interface SettingsStorageOperationsConfig { - documentClient: DynamoDBDocument; -} - -const SORT_KEY = "A"; - -export class SettingsStorageOperations implements FileManagerSettingsStorageOperations { - private readonly _entity: Entity; - - public constructor({ documentClient }: SettingsStorageOperationsConfig) { - this._entity = createStandardEntity({ - table: createTable({ documentClient }), - name: "FM.Settings" - }); - } - - public async get({ - tenant - }: FileManagerStorageOperationsGetSettingsParams): Promise { - try { - const settings = await get<{ data: FileManagerSettings }>({ - entity: this._entity, - keys: { - PK: `T#${tenant}#FM#SETTINGS`, - SK: SORT_KEY - } - }); - - return settings?.data || null; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not fetch the FileManager settings.", - ex.code || "GET_SETTINGS_ERROR" - ); - } - } - - public async create({ - data - }: FileManagerSettingsStorageOperationsCreateParams): Promise { - const original = await this.get({ tenant: data.tenant }); - - if (original) { - return await this.update({ original, data }); - } - - try { - await put({ - entity: this._entity, - item: { - PK: `T#${data.tenant}#FM#SETTINGS`, - SK: SORT_KEY, - TYPE: "fm.settings", - data - } - }); - return data; - } catch (ex) { - throw new WebinyError( - ex.message || "Cannot create FileManager settings.", - ex.code || "CREATE_FM_SETTINGS_ERROR", - { - data - } - ); - } - } - - public async update({ - data - }: FileManagerSettingsStorageOperationsUpdateParams): Promise { - try { - await put({ - entity: this._entity, - item: { - PK: `T#${data.tenant}#FM#SETTINGS`, - SK: SORT_KEY, - TYPE: "fm.settings", - data - } - }); - return data; - } catch (ex) { - throw new WebinyError( - ex.message || "Cannot update FileManager settings.", - ex.code || "UPDATE_FM_SETTINGS_ERROR", - { - data - } - ); - } - } - - public async delete({ tenant }: FileManagerStorageOperationsDeleteSettings): Promise { - await deleteItem({ - entity: this._entity, - keys: { - PK: `T#${tenant}#FM#SETTINGS`, - SK: SORT_KEY - } - }); - } -} diff --git a/packages/api-file-manager-ddb/src/plugins/SettingsAttributePlugin.ts b/packages/api-file-manager-ddb/src/plugins/SettingsAttributePlugin.ts deleted file mode 100644 index 0dd4e10a925..00000000000 --- a/packages/api-file-manager-ddb/src/plugins/SettingsAttributePlugin.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AttributePluginParams } from "@webiny/db-dynamodb/plugins/definitions/AttributePlugin.js"; -import { AttributePlugin } from "@webiny/db-dynamodb/plugins/definitions/AttributePlugin.js"; - -export class SettingsAttributePlugin extends AttributePlugin { - public constructor(params: Omit) { - super({ - ...params, - entity: "FM.Settings" - }); - } -} diff --git a/packages/api-file-manager-ddb/src/plugins/index.ts b/packages/api-file-manager-ddb/src/plugins/index.ts deleted file mode 100644 index 9f124a24c7d..00000000000 --- a/packages/api-file-manager-ddb/src/plugins/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./SettingsAttributePlugin.js"; diff --git a/packages/api-file-manager/MIGRATION_PLAN.md b/packages/api-file-manager/MIGRATION_PLAN.md index a20e3e9af92..31dc2e6f9d6 100644 --- a/packages/api-file-manager/MIGRATION_PLAN.md +++ b/packages/api-file-manager/MIGRATION_PLAN.md @@ -110,22 +110,15 @@ SettingsNotFoundError // Settings not found SettingsUpdateError // Error updating settings ``` -#### Abstractions (`domain/settings/abstractions.ts`) - -```typescript -FileManagerConfig { // Settings configuration - uploadMinFileSize: number; - uploadMaxFileSize: number; - srcPrefix: string; -} -``` - #### Types (`domain/settings/types.ts`) ```typescript -FileManagerSettings // Settings entity +FileManagerSettings // Settings entity (loaded from DB) +UpdateSettingsInput // Input for updating settings ``` +**Note:** Unlike RecordLocking which has runtime config, FileManager settings are stored in the database and accessed via GetSettings/UpdateSettings use cases. No abstraction needed. + --- ## Feature Dependencies (Implementation Order) @@ -567,21 +560,17 @@ export interface ICreateFileUseCase { **Registers:** - All sub-features in dependency order - Domain abstractions: - - `FileModel` (from `domain/file/abstractions.ts`) - - `FileManagerConfig` (from `domain/settings/abstractions.ts`) + - `FileModel` (from `domain/file/abstractions.ts`) via `container.registerInstance()` **Configuration:** ```typescript { model: CmsModel; # The fmFile model - config: { - uploadMinFileSize: number; - uploadMaxFileSize: number; - srcPrefix: string; - } } ``` +**Note:** Settings are stored in DB and accessed via GetSettings/UpdateSettings use cases. No runtime config needed. + --- ## Event Types @@ -685,8 +674,9 @@ From `@webiny/api-headless-cms/features/contentModel/`: 3. Create `domain/file/abstractions.ts` for FileModel abstraction 4. Create `domain/file/types.ts` for file domain types 5. Create `domain/settings/errors.ts` with all settings error types (extend BaseError) -6. Create `domain/settings/abstractions.ts` for FileManagerConfig abstraction -7. Create `domain/settings/types.ts` for settings domain types +6. Create `domain/settings/types.ts` for settings domain types + +**Note:** No abstractions file for settings - settings are loaded from DB via use cases, not runtime config. ### Phase 2: Level 0 Features (2 features) Each feature includes: abstractions, use case implementation, repository (if needed), events decorator (if needed), and feature registration. @@ -757,7 +747,7 @@ Each feature includes: abstractions, repository, use case, events decorator, and ### Phase 6: Integration 17. **FileManagerFeature** (`features/FileManagerFeature.ts`) - Composite feature that registers all sub-features in dependency order - - Registers FileModel and FileManagerConfig via container.registerInstance + - Registers FileModel via container.registerInstance (no config needed) 18. Update GraphQL schema (`graphql/schema.ts`) - Replace `context.fileManager.*` with `context.container.resolve(UseCase)` @@ -765,7 +755,7 @@ Each feature includes: abstractions, repository, use case, events decorator, and 19. Update context setup - Register FileManagerFeature in main plugin - - Pass CMS model and config to feature + - Pass CMS model to feature (settings from DB, no runtime config) - Remove old CRUD factory pattern --- @@ -838,8 +828,9 @@ features/file/CreateFile/ ## Estimated Effort -- **Domain Layer** (7 files): 3-4 hours +- **Domain Layer** (6 files): 2-3 hours - Move model file, create errors, abstractions, types for both subdomains + - Note: No settings abstraction needed (settings from DB) - **Level 0 Features** (2 features, 5 files): 4-6 hours - GetSettings (3 files), GetFile (4 files with repository) - **Level 1 Features** (3 features, 11 files): 8-12 hours @@ -873,5 +864,6 @@ features/file/CreateFile/ 10. ✅ No breaking changes to public API (GraphQL schema remains the same) 11. ✅ FileModel registered as abstraction via `container.registerInstance()` 12. ✅ Each feature is complete vertical slice (abstractions, repo, use case, events, registration) -13. ✅ Settings features use `@webiny/api-core` GetSettings/UpdateSettings +13. ✅ Settings features use `@webiny/api-core` GetSettings/UpdateSettings (no runtime config) 14. ✅ All errors extend BaseError from `@webiny/feature/api` +15. ✅ Settings loaded from DB, not runtime config (unlike RecordLocking) diff --git a/packages/api-file-manager/src/FileManagerContextSetup.ts b/packages/api-file-manager/src/FileManagerContextSetup.ts index 3c75adaa9c9..edce4c24a4f 100644 --- a/packages/api-file-manager/src/FileManagerContextSetup.ts +++ b/packages/api-file-manager/src/FileManagerContextSetup.ts @@ -1,13 +1,13 @@ -import type { FileManagerAliasesStorageOperations, FilePermission } from "~/types.js"; +// @ts-nocheck Being removed +import type { FileAliasesStorageOperations, FilePermission } from "~/types.js"; import { type FileManagerContext, type SettingsPermission } from "~/types.js"; import type { FileManagerConfig } from "~/createFileManager/types.js"; import { createFileManager } from "~/createFileManager/index.js"; import { FileStorage } from "~/storage/FileStorage.js"; import WebinyError from "@webiny/error"; -import { createFileModel, FILE_MODEL_ID } from "~/cmsFileStorage/file.model.js"; +import { createFileModel, FILE_MODEL_ID } from "~/domain/file/fileModel.js"; import { CmsFilesStorage } from "~/cmsFileStorage/CmsFilesStorage.js"; -import { CmsModelModifierPlugin } from "~/modelModifier/CmsModelModifier.js"; -import { CmsModelPlugin, isHeadlessCmsReady } from "@webiny/api-headless-cms"; +import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; import { FilesPermissions } from "~/createFileManager/permissions/FilesPermissions.js"; import { SettingsPermissions } from "~/createFileManager/permissions/SettingsPermissions.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; @@ -22,18 +22,6 @@ export class FileManagerContextSetup { } async setupContext(storageOperations: FileManagerConfig["storageOperations"]) { - if (storageOperations.beforeInit) { - await storageOperations.beforeInit(this.context); - } - - const fileStorageOps = await this.context.security.withoutAuthorization(() => { - return this.setupCmsStorageOperations(storageOperations.aliases); - }); - - if (fileStorageOps) { - storageOperations.files = fileStorageOps; - } - const filesPermissions = new FilesPermissions({ getIdentity: () => { const identityContext = this.context.container.resolve(IdentityContext); @@ -58,7 +46,6 @@ export class FileManagerContextSetup { filesPermissions, settingsPermissions, getTenantId: this.getTenantId.bind(this), - getLocaleCode: this.getLocaleCode.bind(this), getIdentity: this.getIdentity.bind(this), getPermissions: this.getPermissions.bind(this), storage: new FileStorage({ @@ -69,10 +56,6 @@ export class FileManagerContextSetup { }); } - private getLocaleCode() { - return getLocale().code; - } - private getIdentity() { return this.context.security.getIdentity(); } @@ -87,26 +70,25 @@ export class FileManagerContextSetup { return this.context.security.getPermissions(name); } - private async setupCmsStorageOperations(aliases: FileManagerAliasesStorageOperations) { + private async setupCmsStorageOperations(aliases: FileAliasesStorageOperations) { if (!(await isHeadlessCmsReady(this.context))) { return; } const withPrivateFiles = this.context.wcp.canUsePrivateFiles(); - // This registers code plugins (model group, models) - const fileModelDefinition = createFileModel({ withPrivateFiles }); - - const modelModifiers = this.context.plugins.byType( - CmsModelModifierPlugin.type - ); - - for (const modifier of modelModifiers) { - await modifier.modifyModel(fileModelDefinition); - } + // TODO: model modifier need to be implemented differently, via CMS model builder + // const modelModifiers = this.context.plugins.byType( + // CmsModelModifierPlugin.type + // ); + // + // for (const modifier of modelModifiers) { + // await modifier.modifyModel(fileModelDefinition); + // } // Finally, register all plugins - this.context.plugins.register([new CmsModelPlugin(fileModelDefinition)]); + const fileModelDefinition = createFileModel({ withPrivateFiles }); + this.context.plugins.register(fileModelDefinition); // Now load the file model registered in the previous step const fileModel = await this.getModel(FILE_MODEL_ID); diff --git a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts index 77c13872b60..93acc41fae9 100644 --- a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts +++ b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts @@ -1,19 +1,7 @@ +// @ts-nocheck This is being removed import omit from "lodash/omit.js"; import type { CmsEntry, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; -import type { - File, - FileManagerAliasesStorageOperations, - FileManagerFilesStorageOperations, - FileManagerFilesStorageOperationsCreateBatchParams, - FileManagerFilesStorageOperationsCreateParams, - FileManagerFilesStorageOperationsDeleteParams, - FileManagerFilesStorageOperationsGetParams, - FileManagerFilesStorageOperationsListParams, - FileManagerFilesStorageOperationsListResponse, - FileManagerFilesStorageOperationsTagsParams, - FileManagerFilesStorageOperationsTagsResponse, - FileManagerFilesStorageOperationsUpdateParams -} from "~/types.js"; +import type { File, FileAliasesStorageOperations } from "~/types.js"; import { ListFilesWhereProcessor } from "~/cmsFileStorage/ListFilesWhereProcessor.js"; import { ListTagsWhereProcessor } from "~/cmsFileStorage/ListTagsWhereProcessor.js"; import { ROOT_FOLDER } from "~/contants.js"; @@ -22,17 +10,17 @@ interface ModelContext { tenant: string; } -export class CmsFilesStorage implements FileManagerFilesStorageOperations { +export class CmsFilesStorage implements FileStorageOperations { private readonly cms: HeadlessCms; private readonly model: CmsModel; - private readonly aliases: FileManagerAliasesStorageOperations; + private readonly aliases: FileAliasesStorageOperations; private readonly filesWhereProcessor: ListFilesWhereProcessor; private readonly tagsWhereProcessor: ListTagsWhereProcessor; static async create(params: { fileModel: CmsModel; cms: HeadlessCms; - aliases: FileManagerAliasesStorageOperations; + aliases: FileAliasesStorageOperations; }) { return new CmsFilesStorage(params.fileModel, params.cms, params.aliases); } @@ -40,7 +28,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { private constructor( fileModel: CmsModel, cms: HeadlessCms, - aliases: FileManagerAliasesStorageOperations + aliases: FileAliasesStorageOperations ) { this.model = fileModel; this.aliases = aliases; @@ -120,7 +108,7 @@ export class CmsFilesStorage implements FileManagerFilesStorageOperations { params: FileManagerFilesStorageOperationsTagsParams ): Promise { const tenant = params.where.tenant; - const model = this.modelWithContext({ tenant}); + const model = this.modelWithContext({ tenant }); const uniqueValues = await this.cms.getUniqueFieldValues(model, { fieldId: "tags", where: { diff --git a/packages/api-file-manager/src/domain/file/abstractions.ts b/packages/api-file-manager/src/domain/file/abstractions.ts new file mode 100644 index 00000000000..2e7dcd62c64 --- /dev/null +++ b/packages/api-file-manager/src/domain/file/abstractions.ts @@ -0,0 +1,12 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; + +/** + * FileModel abstraction - represents the fmFile CMS model. + * This will be registered via container.registerInstance in the composite feature. + */ +export const FileModel = createAbstraction("FileModel"); + +export namespace FileModel { + export type Interface = CmsModel; +} diff --git a/packages/api-file-manager/src/domain/file/errors.ts b/packages/api-file-manager/src/domain/file/errors.ts new file mode 100644 index 00000000000..d94b3e3f570 --- /dev/null +++ b/packages/api-file-manager/src/domain/file/errors.ts @@ -0,0 +1,109 @@ +import { BaseError } from "@webiny/feature/api"; + +export class FilePersistenceError extends BaseError { + override readonly code = "FileManager/File/PersistenceError" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class FileNotFoundError extends BaseError<{ id: string }> { + override readonly code = "FileManager/File/NotFoundError" as const; + + constructor(id: string) { + super({ + message: `File with ID "${id}" not found.`, + data: { id } + }); + } +} + +export class FileNotAuthorizedError extends BaseError { + override readonly code = "FileManager/File/NotAuthorizedError" as const; + + constructor() { + super({ + message: `Not authorized.` + }); + } +} + +export class FileListError extends BaseError { + override readonly code = "FileManager/File/ListError" as const; + + constructor(error: Error) { + super({ + message: `Error listing files: ${error.message}` + }); + } +} + +export class FileCreateError extends BaseError { + override readonly code = "FileManager/File/CreateError" as const; + + constructor(error: Error) { + super({ + message: `Error creating file: ${error.message}` + }); + } +} + +export class FileUpdateError extends BaseError { + override readonly code = "FileManager/File/UpdateError" as const; + + constructor(error: Error) { + super({ + message: `Error updating file: ${error.message}` + }); + } +} + +export class FileDeleteError extends BaseError { + override readonly code = "FileManager/File/DeleteError" as const; + + constructor(error: Error) { + super({ + message: `Error deleting file: ${error.message}` + }); + } +} + +export class FileAlreadyExistsError extends BaseError<{ key: string }> { + override readonly code = "FileManager/File/AlreadyExistsError" as const; + + constructor(data: { key: string }) { + super({ + message: `File with key "${data.key}" already exists.`, + data + }); + } +} + +export class InvalidFileSizeError extends BaseError<{ + size: number; + minSize: number; + maxSize: number; +}> { + override readonly code = "FileManager/File/InvalidFileSizeError" as const; + + constructor(data: { size: number; minSize: number; maxSize: number }) { + super({ + message: `File size ${data.size} bytes is outside allowed range (${data.minSize}-${data.maxSize} bytes).`, + data + }); + } +} + +export class InvalidFileTypeError extends BaseError<{ type: string }> { + override readonly code = "FileManager/File/InvalidFileTypeError" as const; + + constructor(data: { type: string }) { + super({ + message: `File type "${data.type}" is not allowed.`, + data + }); + } +} diff --git a/packages/api-file-manager/src/cmsFileStorage/file.model.ts b/packages/api-file-manager/src/domain/file/fileModel.ts similarity index 86% rename from packages/api-file-manager/src/cmsFileStorage/file.model.ts rename to packages/api-file-manager/src/domain/file/fileModel.ts index a5cd2a355c4..2c8c54600f1 100644 --- a/packages/api-file-manager/src/cmsFileStorage/file.model.ts +++ b/packages/api-file-manager/src/domain/file/fileModel.ts @@ -1,4 +1,4 @@ -import { createPrivateModel, createModelField } from "@webiny/api-headless-cms"; +import { createCmsModel, createPrivateModel, createModelField } from "@webiny/api-headless-cms"; const required = () => { return { @@ -175,17 +175,19 @@ export const createFileModel = (params: CreateFileModelDefinitionParams) => { fields.push(accessControlField()); } - return createPrivateModel({ - name: "FmFile", - modelId: FILE_MODEL_ID, - titleFieldId: "name", - authorization: { - // Disables base permission checks, but leaves FLP checks enabled. - permissions: false - - // We're leaving FLP enabled (no need to set `flp: true`). - // flp: true - }, - fields - }); + return createCmsModel( + createPrivateModel({ + name: "FmFile", + modelId: FILE_MODEL_ID, + titleFieldId: "name", + authorization: { + // Disables base permission checks, but leaves FLP checks enabled. + permissions: false + + // We're leaving FLP enabled (no need to set `flp: true`). + // flp: true + }, + fields + }) + ); }; diff --git a/packages/api-file-manager/src/domain/file/types.ts b/packages/api-file-manager/src/domain/file/types.ts new file mode 100644 index 00000000000..cf13aa5c774 --- /dev/null +++ b/packages/api-file-manager/src/domain/file/types.ts @@ -0,0 +1,71 @@ +export type PublicAccess = { + type: "public"; +}; + +export type PrivateAuthenticatedAccess = { + type: "private-authenticated"; +}; + +export type FileAccess = PublicAccess | PrivateAuthenticatedAccess; + +export interface CreatedBy { + id: string; + displayName: string | null; + type: string; +} + +export interface File { + id: string; + key: string; + size: number; + type: string; + name: string; + meta: Record; + accessControl?: FileAccess; + location: { + folderId: string; + }; + tags: string[]; + aliases: string[]; + + createdOn: string; + modifiedOn: string | null; + savedOn: string; + createdBy: CreatedBy; + modifiedBy: CreatedBy | null; + savedBy: CreatedBy; + extensions?: Record; + + tenant: string; + webinyVersion: string; +} + +export interface FileAlias { + tenant: string; + fileId: string; + alias: string; +} + +export interface FileInput { + id: string; + + // Entry-level fields (we don't use revisions for files) + createdOn?: string | Date; + modifiedOn?: string | Date; + savedOn?: string | Date; + createdBy?: CreatedBy; + modifiedBy?: CreatedBy; + savedBy?: CreatedBy; + + key: string; + name: string; + size: number; + type: string; + meta: Record; + location?: { + folderId: string; + }; + tags: string[]; + aliases: string[]; + extensions?: Record; +} diff --git a/packages/api-file-manager/src/features/settings/constants.ts b/packages/api-file-manager/src/domain/settings/constants.ts similarity index 100% rename from packages/api-file-manager/src/features/settings/constants.ts rename to packages/api-file-manager/src/domain/settings/constants.ts diff --git a/packages/api-file-manager/src/domain/settings/errors.ts b/packages/api-file-manager/src/domain/settings/errors.ts new file mode 100644 index 00000000000..0042228fe68 --- /dev/null +++ b/packages/api-file-manager/src/domain/settings/errors.ts @@ -0,0 +1,21 @@ +import { BaseError } from "@webiny/feature/api"; + +export class SettingsNotFoundError extends BaseError { + override readonly code = "FileManager/Settings/NotFoundError" as const; + + constructor() { + super({ + message: "File manager settings not found." + }); + } +} + +export class SettingsUpdateError extends BaseError { + override readonly code = "FileManager/Settings/UpdateError" as const; + + constructor(error: Error) { + super({ + message: `Error updating settings: ${error.message}` + }); + } +} diff --git a/packages/api-file-manager/src/domain/settings/types.ts b/packages/api-file-manager/src/domain/settings/types.ts new file mode 100644 index 00000000000..abeb731d479 --- /dev/null +++ b/packages/api-file-manager/src/domain/settings/types.ts @@ -0,0 +1,12 @@ +export interface FileManagerSettings { + tenant: string; + uploadMinFileSize: number; + uploadMaxFileSize: number; + srcPrefix: string; +} + +export interface UpdateSettingsInput { + uploadMinFileSize?: number; + uploadMaxFileSize?: number; + srcPrefix?: string; +} diff --git a/packages/api-file-manager/src/enterprise/applyThreatScanning.ts b/packages/api-file-manager/src/enterprise/applyThreatScanning.ts index e08b610e17b..f07827d88d8 100644 --- a/packages/api-file-manager/src/enterprise/applyThreatScanning.ts +++ b/packages/api-file-manager/src/enterprise/applyThreatScanning.ts @@ -1,6 +1,7 @@ import type { FileManagerContext } from "~/types.js"; import { decorateContext } from "@webiny/api"; +// TODO: implement this via a use case decorator export const applyThreatScanning = (context: FileManagerContext["fileManager"]) => { return decorateContext(context, { createFile: decoratee => (data, meta) => { diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts new file mode 100644 index 00000000000..0367b955798 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts @@ -0,0 +1,34 @@ +import { Result } from "@webiny/feature/api"; +import { CreateFileRepository as RepositoryAbstraction } from "./abstractions.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { FileModel } from "~/domain/file/abstractions.js"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; +import { FilePersistenceError } from "~/domain/file/errors.js"; + +class CreateFileRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async create(data: FileInput): Promise> { + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.createEntry.execute(this.fileModel, data); + }); + + if (result.isFail()) { + return Result.fail(new FilePersistenceError(result.error)); + } + + const file = EntryToFileMapper.toFile(result.value); + return Result.ok(file); + } +} + +export const CreateFileRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateFileRepositoryImpl, + dependencies: [CreateEntryUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts new file mode 100644 index 00000000000..1fb584a4662 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts @@ -0,0 +1,95 @@ +import { Result } from "@webiny/feature/api"; +import { + CreateFileUseCase as UseCaseAbstraction, + CreateFileInput, + CreateFileRepository +} from "./abstractions.js"; +import { GetSettingsUseCase } from "../../settings/GetSettings/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { FileNotAuthorizedError, InvalidFileSizeError } from "~/domain/file/errors.js"; +import { FileBeforeCreateEvent, FileAfterCreateEvent } from "./events.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; + +class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private repository: CreateFileRepository.Interface, + private getSettings: GetSettingsUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + input: CreateFileInput, + meta?: Record + ): Promise> { + const hasPermission = await this.filePermissions.ensure({ rwd: "w" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + const validationResult = await this.validateInput(input); + if (validationResult.isFail()) { + return Result.fail(validationResult.error); + } + + const [id] = input.key.split("/"); + + // Prepare file input + const fileInput: FileInput = { + id: input.id || id, + key: input.key, + name: input.name, + size: input.size, + type: input.type, + meta: input.meta || {}, + location: input.location || { folderId: "root" }, + tags: input.tags || [], + aliases: input.aliases || [], + extensions: meta || {} + }; + + await this.eventPublisher.publish(new FileBeforeCreateEvent({ file: fileInput, meta })); + + const result = await this.repository.create(fileInput); + + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish(new FileAfterCreateEvent({ file: result.value, meta })); + + return Result.ok(result.value); + } + + private async validateInput( + input: CreateFileInput + ): Promise> { + const settingsResult = await this.getSettings.execute(); + + const settings = settingsResult.value; + + if (settings) { + // Validate file size + if ( + input.size < settings.uploadMinFileSize || + input.size > settings.uploadMaxFileSize + ) { + return Result.fail( + new InvalidFileSizeError({ + size: input.size, + minSize: settings.uploadMinFileSize, + maxSize: settings.uploadMaxFileSize + }) + ); + } + } + + return Result.ok(); + } +} + +export const CreateFileUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateFileUseCaseImpl, + dependencies: [FilePermissions, CreateFileRepository, GetSettingsUseCase, EventPublisher] +}); diff --git a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts new file mode 100644 index 00000000000..c24d9ff8185 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts @@ -0,0 +1,68 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { + type FilePersistenceError, + type InvalidFileSizeError, + type FileAlreadyExistsError, + FileNotAuthorizedError +} from "~/domain/file/errors.js"; + +export interface CreateFileInput { + id?: string; + key: string; + size: number; + type: string; + name: string; + meta?: Record; + tags?: string[]; + location?: { folderId: string }; + aliases?: string[]; +} + +/** + * CreateFile repository interface + */ +export interface ICreateFileRepository { + create(data: FileInput): Promise>; +} + +export interface ICreateFileRepositoryErrors { + persistence: FilePersistenceError; +} + +type RepositoryError = ICreateFileRepositoryErrors[keyof ICreateFileRepositoryErrors]; + +export const CreateFileRepository = + createAbstraction("CreateFileRepository"); + +export namespace CreateFileRepository { + export type Interface = ICreateFileRepository; + export type Error = RepositoryError; +} + +/** + * CreateFile use case interface + */ +export interface ICreateFileUseCase { + execute( + input: CreateFileInput, + meta?: Record + ): Promise>; +} + +export interface ICreateFileUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + persistence: FilePersistenceError; + invalidSize: InvalidFileSizeError; + alreadyExists: FileAlreadyExistsError; +} + +type UseCaseError = ICreateFileUseCaseErrors[keyof ICreateFileUseCaseErrors]; + +export const CreateFileUseCase = createAbstraction("CreateFileUseCase"); + +export namespace CreateFileUseCase { + export type Interface = ICreateFileUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/CreateFile/events.ts b/packages/api-file-manager/src/features/file/CreateFile/events.ts new file mode 100644 index 00000000000..d2abe5dff3c --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/events.ts @@ -0,0 +1,54 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { File, FileInput } from "~/domain/file/types.js"; + +// ============================================================================ +// FileBeforeCreate Event +// ============================================================================ + +export interface FileBeforeCreatePayload { + file: FileInput; + meta?: Record; +} + +export class FileBeforeCreateEvent extends DomainEvent { + eventType = "FileManager/File/BeforeCreate" as const; + + getHandlerAbstraction() { + return FileBeforeCreateHandler; + } +} + +export const FileBeforeCreateHandler = + createAbstraction>("FileBeforeCreateHandler"); + +export namespace FileBeforeCreateHandler { + export type Interface = IEventHandler; + export type Event = FileBeforeCreateEvent; +} + +// ============================================================================ +// FileAfterCreate Event +// ============================================================================ + +export interface FileAfterCreatePayload { + file: File; + meta?: Record; +} + +export class FileAfterCreateEvent extends DomainEvent { + eventType = "FileManager/File/AfterCreate" as const; + + getHandlerAbstraction() { + return FileAfterCreateHandler; + } +} + +export const FileAfterCreateHandler = + createAbstraction>("FileAfterCreateHandler"); + +export namespace FileAfterCreateHandler { + export type Interface = IEventHandler; + export type Event = FileAfterCreateEvent; +} diff --git a/packages/api-file-manager/src/features/file/CreateFile/feature.ts b/packages/api-file-manager/src/features/file/CreateFile/feature.ts new file mode 100644 index 00000000000..895c2c54f3e --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateFileRepository } from "./CreateFileRepository.js"; +import { CreateFileUseCase } from "./CreateFileUseCase.js"; + +export const CreateFileFeature = createFeature({ + name: "FileManager.CreateFile", + register(container) { + container.register(CreateFileUseCase); + container.register(CreateFileRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/CreateFile/index.ts b/packages/api-file-manager/src/features/file/CreateFile/index.ts new file mode 100644 index 00000000000..c3e9aa679ce --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFile/index.ts @@ -0,0 +1 @@ +export { CreateFileUseCase } from "./abstractions.js"; diff --git a/packages/api-file-manager/src/features/file/FileUrlGenerator/abstractions.ts b/packages/api-file-manager/src/features/file/FileUrlGenerator/abstractions.ts new file mode 100644 index 00000000000..55316fd1efb --- /dev/null +++ b/packages/api-file-manager/src/features/file/FileUrlGenerator/abstractions.ts @@ -0,0 +1,11 @@ +import { createAbstraction } from "@webiny/feature/api"; + +interface IFileUrlGenerator { + generateUrl(file: File): string; +} + +export const FileUrlGenerator = createAbstraction("IFileUrlGenerator"); + +export namespace FileUrlGenerator { + export type Interface = IFileUrlGenerator; +} diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts new file mode 100644 index 00000000000..737855395d3 --- /dev/null +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts @@ -0,0 +1,39 @@ +import { Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { GetFileRepository as RepositoryAbstraction } from "./abstractions.js"; +import { FileModel } from "~/domain/file/abstractions.js"; +import type { File } from "~/domain/file/types.js"; +import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; +import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; + +class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async getById(id: string): Promise> { + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.getEntryById.execute(this.fileModel, id); + }); + + if (result.isFail()) { + const error = result.error; + if (error.code === "Cms/Entry/NotFound") { + return Result.fail(new FileNotFoundError(id)); + } + return Result.fail(new FilePersistenceError(result.error)); + } + + const file = EntryToFileMapper.toFile(result.value); + + return Result.ok(file); + } +} + +export const GetFileRepository = RepositoryAbstraction.createImplementation({ + implementation: GetFileRepositoryImpl, + dependencies: [GetEntryByIdUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts new file mode 100644 index 00000000000..80edf32d725 --- /dev/null +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts @@ -0,0 +1,45 @@ +import { Result } from "@webiny/feature/api"; +import { + GetFileUseCase as UseCaseAbstraction, + GetFileInput, + GetFileRepository +} from "./abstractions.js"; +import type { File } from "~/domain/file/types.js"; +import { FileNotAuthorizedError } from "~/domain/file/errors.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; + +class GetFileUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private repository: GetFileRepository.Interface + ) {} + + async execute(input: GetFileInput): Promise> { + // Check read permission + const hasPermission = await this.filePermissions.ensure({ rwd: "r" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + const result = await this.repository.getById(input.id); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const file = result.value; + + // Check ownership permission + const hasOwnershipPermission = await this.filePermissions.ensure({ owns: file.createdBy }); + if (!hasOwnershipPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + return Result.ok(file); + } +} + +export const GetFileUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetFileUseCaseImpl, + dependencies: [FilePermissions, GetFileRepository] +}); diff --git a/packages/api-file-manager/src/features/file/GetFile/abstractions.ts b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts new file mode 100644 index 00000000000..7bd0f3ae5b0 --- /dev/null +++ b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { File } from "~/domain/file/types.js"; +import { + type FilePersistenceError, + type FileNotFoundError, + FileNotAuthorizedError +} from "~/domain/file/errors.js"; + +export interface GetFileInput { + id: string; +} + +/** + * GetFile repository interface + */ +export interface IGetFileRepository { + getById(id: string): Promise>; +} + +export interface IGetFileRepositoryErrors { + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type RepositoryError = IGetFileRepositoryErrors[keyof IGetFileRepositoryErrors]; + +export const GetFileRepository = createAbstraction("GetFileRepository"); + +export namespace GetFileRepository { + export type Interface = IGetFileRepository; + export type Error = RepositoryError; +} + +/** + * GetFile use case interface + */ +export interface IGetFileUseCase { + execute(input: GetFileInput): Promise>; +} + +export interface IGetFileUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type UseCaseError = IGetFileUseCaseErrors[keyof IGetFileUseCaseErrors]; + +export const GetFileUseCase = createAbstraction("GetFileUseCase"); + +export namespace GetFileUseCase { + export type Interface = IGetFileUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/GetFile/feature.ts b/packages/api-file-manager/src/features/file/GetFile/feature.ts new file mode 100644 index 00000000000..eeae0339535 --- /dev/null +++ b/packages/api-file-manager/src/features/file/GetFile/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetFileRepository } from "./GetFileRepository.js"; +import { GetFileUseCase } from "./GetFileUseCase.js"; + +export const GetFileFeature = createFeature({ + name: "FileManager.GetFile", + register(container) { + container.register(GetFileUseCase); + container.register(GetFileRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/GetFile/index.ts b/packages/api-file-manager/src/features/file/GetFile/index.ts new file mode 100644 index 00000000000..e75470302f7 --- /dev/null +++ b/packages/api-file-manager/src/features/file/GetFile/index.ts @@ -0,0 +1 @@ +export { GetFileUseCase } from "./abstractions.js"; diff --git a/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts new file mode 100644 index 00000000000..5259f4890d1 --- /dev/null +++ b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts @@ -0,0 +1,28 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { File } from "~/domain/file/types.js"; + +export class EntryToFileMapper { + static toFile(entry: CmsEntry): File { + return { + id: entry.entryId, + createdOn: entry.createdOn, + modifiedOn: entry.modifiedOn, + savedOn: entry.savedOn, + createdBy: entry.createdBy, + modifiedBy: entry.modifiedBy, + savedBy: entry.savedBy, + name: entry.values.name, + key: entry.values.key, + size: entry.values.size, + type: entry.values.type, + meta: entry.values.meta || {}, + accessControl: entry.values.accessControl, + location: entry.values.location || { folderId: "root" }, + tags: entry.values.tags || [], + aliases: entry.values.aliases || [], + tenant: entry.tenant, + webinyVersion: entry.webinyVersion, + extensions: entry.values.extensions + }; + } +} diff --git a/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts b/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts new file mode 100644 index 00000000000..a4eaaae3b23 --- /dev/null +++ b/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts @@ -0,0 +1,31 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { File } from "~/domain/file/types.js"; + +export class FileToEntryMapper { + static toEntry(file: File): Partial { + return { + id: `${file.id}#0001`, + entryId: file.id, + createdOn: file.createdOn, + modifiedOn: file.modifiedOn, + savedOn: file.savedOn, + createdBy: file.createdBy, + modifiedBy: file.modifiedBy, + savedBy: file.savedBy, + location: file.location || { folderId: "root" }, + webinyVersion: file.webinyVersion, + values: { + name: file.name, + key: file.key, + size: file.size, + type: file.type, + meta: file.meta || {}, + accessControl: file.accessControl, + tags: file.tags || [], + aliases: file.aliases || [], + tenant: file.tenant, + extensions: file.extensions + } + }; + } +} diff --git a/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts new file mode 100644 index 00000000000..2cdc928f329 --- /dev/null +++ b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts @@ -0,0 +1,29 @@ +import { Result } from "@webiny/feature/api"; +import { + GetSettingsUseCase as UseCaseAbstraction +} from "./abstractions.js"; +import { GetSettings } from "@webiny/api-core/features/settings/GetSettings"; +import type { FileManagerSettings } from "~/domain/settings/types.js"; + +const SETTINGS_NAME = "file-manager"; + +class GetSettingsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private getSettings: GetSettings.Interface + ) {} + + async execute(): Promise> { + const result = await this.getSettings.execute({ name: SETTINGS_NAME }); + + if (result.isFail()) { + return Result.ok(null); + } + + return Result.ok(result.value); + } +} + +export const GetSettingsUseCase = UseCaseAbstraction.createImplementation({ + implementation: GetSettingsUseCaseImpl, + dependencies: [GetSettings] +}); diff --git a/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts new file mode 100644 index 00000000000..52eb0453c56 --- /dev/null +++ b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts @@ -0,0 +1,16 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { FileManagerSettings } from "~/domain/settings/types.js"; + +/** + * GetSettings use case - retrieves file manager settings. + */ +export interface IGetSettingsUseCase { + execute(): Promise>; +} + +export const GetSettingsUseCase = createAbstraction("GetSettingsUseCase"); + +export namespace GetSettingsUseCase { + export type Interface = IGetSettingsUseCase; +} diff --git a/packages/api-file-manager/src/features/settings/GetSettings/feature.ts b/packages/api-file-manager/src/features/settings/GetSettings/feature.ts new file mode 100644 index 00000000000..441a757718c --- /dev/null +++ b/packages/api-file-manager/src/features/settings/GetSettings/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { GetSettingsUseCase } from "./GetSettingsUseCase.js"; + +export const GetSettingsFeature = createFeature({ + name: "FileManager.GetSettings", + register(container) { + container.register(GetSettingsUseCase); + } +}); diff --git a/packages/api-file-manager/src/features/settings/SettingsInstaller.ts b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts similarity index 94% rename from packages/api-file-manager/src/features/settings/SettingsInstaller.ts rename to packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts index a9a402a1067..546522d3580 100644 --- a/packages/api-file-manager/src/features/settings/SettingsInstaller.ts +++ b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts @@ -3,7 +3,7 @@ import { createImplementation } from "@webiny/feature/api"; import { AppInstaller } from "@webiny/api-core/features/InstallTenant"; import { UpdateSettings } from "@webiny/api-core/features/UpdateSettings"; import { DeleteSettings } from "@webiny/api-core/features/DeleteSettings"; -import { FILE_MANAGER_GENERAL_SETTINGS } from "./constants.js"; +import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; class SettingsInstallerImpl implements AppInstaller.Interface { readonly alwaysRun = true; diff --git a/packages/api-file-manager/src/features/settings/feature.ts b/packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts similarity index 100% rename from packages/api-file-manager/src/features/settings/feature.ts rename to packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts new file mode 100644 index 00000000000..d789169c38e --- /dev/null +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts @@ -0,0 +1,97 @@ +import { Result } from "@webiny/feature/api"; +import { + UpdateSettingsUseCase as UseCaseAbstraction +} from "./abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; +import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; + +/** + * Event types for settings lifecycle + */ +export interface SettingsBeforeUpdateEvent { + original: FileManagerSettings | null; + settings: FileManagerSettings; + input: UpdateSettingsInput; +} + +export interface SettingsAfterUpdateEvent { + original: FileManagerSettings | null; + settings: FileManagerSettings; + input: UpdateSettingsInput; +} + +export interface SettingsUpdateErrorEvent { + input: UpdateSettingsInput; + error: Error; +} + +class UpdateSettingsEventsDecoratorImpl implements UseCaseAbstraction.Interface { + constructor( + private useCase: UseCaseAbstraction.Interface, + private eventPublisher: EventPublisher.Interface, + private getSettings: GetSettingsUseCase.Interface + ) {} + + async execute(input: UpdateSettingsInput): Promise> { + // Get original settings + const originalResult = await this.getSettings.execute(); + const original = originalResult.value; + + try { + // Execute use case + const result = await this.useCase.execute(input); + + if (result.isFail()) { + // Publish error event + await this.eventPublisher.publish({ + type: "fileManager.settings.update.error", + data: { + input, + error: result.error + } + }); + return result; + } + + const settings = result.value; + + // Publish before event + await this.eventPublisher.publish({ + type: "fileManager.settings.update.before", + data: { + original, + settings, + input + } + }); + + // Publish after event + await this.eventPublisher.publish({ + type: "fileManager.settings.update.after", + data: { + original, + settings, + input + } + }); + + return result; + } catch (error) { + // Publish error event for exceptions + await this.eventPublisher.publish({ + type: "fileManager.settings.update.error", + data: { + input, + error: error as Error + } + }); + throw error; + } + } +} + +export const UpdateSettingsEventsDecorator = UseCaseAbstraction.createImplementation({ + implementation: UpdateSettingsEventsDecoratorImpl, + dependencies: [UseCaseAbstraction, EventPublisher, GetSettingsUseCase] +}); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts new file mode 100644 index 00000000000..216d2a04247 --- /dev/null +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -0,0 +1,46 @@ +import { Result } from "@webiny/feature/api"; +import { + UpdateSettingsUseCase as UseCaseAbstraction +} from "./abstractions.js"; +import { UpdateSettings } from "@webiny/api-core/features/settings/UpdateSettings"; +import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; +import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; +import { SettingsUpdateError } from "~/domain/settings/errors.js"; + +const SETTINGS_NAME = "file-manager"; + +class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private updateSettings: UpdateSettings.Interface, + private getSettings: GetSettingsUseCase.Interface + ) {} + + async execute(input: UpdateSettingsInput): Promise> { + try { + // Get existing settings to merge with new data + const existingResult = await this.getSettings.execute(); + const existing = existingResult.value; + + const result = await this.updateSettings.execute({ + name: SETTINGS_NAME, + data: { + ...existing, + ...input + } + }); + + if (result.isFail()) { + return Result.fail(new SettingsUpdateError(result.error)); + } + + return Result.ok(result.value); + } catch (error) { + return Result.fail(new SettingsUpdateError(error as Error)); + } + } +} + +export const UpdateSettingsUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateSettingsUseCaseImpl, + dependencies: [UpdateSettings, GetSettingsUseCase] +}); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts new file mode 100644 index 00000000000..81a5f353d42 --- /dev/null +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts @@ -0,0 +1,24 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; +import type { SettingsUpdateError } from "~/domain/settings/errors.js"; + +/** + * UpdateSettings use case interface + */ +export interface IUpdateSettingsUseCase { + execute(input: UpdateSettingsInput): Promise>; +} + +export interface IUpdateSettingsUseCaseErrors { + updateError: SettingsUpdateError; +} + +type UseCaseError = IUpdateSettingsUseCaseErrors[keyof IUpdateSettingsUseCaseErrors]; + +export const UpdateSettingsUseCase = createAbstraction("UpdateSettingsUseCase"); + +export namespace UpdateSettingsUseCase { + export type Interface = IUpdateSettingsUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts new file mode 100644 index 00000000000..ed30d8d9d0d --- /dev/null +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts @@ -0,0 +1,12 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateSettingsUseCase } from "./UpdateSettingsUseCase.js"; +import { UpdateSettingsEventsDecorator } from "./UpdateSettingsEventsDecorator.js"; + +export const UpdateSettingsFeature = createFeature({ + name: "FileManager.UpdateSettings", + register(container) { + container.register(UpdateSettingsUseCase); + // Register the decorator which wraps the use case with events + container.register(UpdateSettingsEventsDecorator); + } +}); diff --git a/packages/api-file-manager/src/features/shared/abstractions.ts b/packages/api-file-manager/src/features/shared/abstractions.ts new file mode 100644 index 00000000000..2d80aa51379 --- /dev/null +++ b/packages/api-file-manager/src/features/shared/abstractions.ts @@ -0,0 +1,19 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { AppPermissions } from "@webiny/api-core/features/security/utils/AppPermissions.js"; +import type { FilePermission, SettingsPermission } from "~/types.js"; + +type IFilePermissions = AppPermissions; + +export const FilePermissions = createAbstraction("FilePermissions"); + +export namespace FilePermissions { + export type Interface = IFilePermissions; +} + +type ISettingsPermissions = AppPermissions; + +export const SettingsPermissions = createAbstraction("SettingsPermission"); + +export namespace SettingsPermissions { + export type Interface = ISettingsPermissions; +} diff --git a/packages/api-file-manager/src/graphql/filesSchema.ts b/packages/api-file-manager/src/graphql/filesSchema.ts index 1bc39abe22a..bff63c21c41 100644 --- a/packages/api-file-manager/src/graphql/filesSchema.ts +++ b/packages/api-file-manager/src/graphql/filesSchema.ts @@ -22,6 +22,7 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { }, FmFile: { async src(file, _, context) { + // TODO: create `FileUrlGenerator` service to use here const settings = await context.fileManager.getSettings(); return (settings?.srcPrefix || "") + file.key; } @@ -60,7 +61,7 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { FmMutation: { async createFile(_, args: any, context) { return resolve(() => { - return context.fileManager.createFile(args.data, args.meta); + return context.fileManager.createFile(args.data); }); }, async createFiles(_, args: any, context) { @@ -75,12 +76,13 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { }, async deleteFile(_, args: any, context) { return resolve(async () => { - // TODO: Ideally, this should work via a lifecycle hook; first we delete a record from DB, then from cloud storage. - const file = await context.fileManager.getFile(args.id); - return await context.fileManager.storage.delete({ - id: file.id, - key: file.key - }); + // TODO: 1) implement via a DeleteFile use case + // TODO: 2) deletion from Cloud storage should be implemented in the `api-file-manager-s3` as an event handler + // const file = await context.fileManager.getFile(args.id); + // return await context.fileManager.storage.delete({ + // id: file.id, + // key: file.key + // }); }); } } diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index 425349e3a46..502b091b27c 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -1,12 +1,14 @@ import { ContextPlugin } from "@webiny/api"; -import type { FileManagerContext } from "~/types.js"; -import { FileManagerContextSetup } from "./FileManagerContextSetup.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import type { FileManagerContext, FilePermission, SettingsPermission } from "~/types.js"; import type { AssetDeliveryParams } from "./delivery/setupAssetDelivery.js"; import { setupAssetDelivery } from "./delivery/setupAssetDelivery.js"; import { createGraphQLSchemaPlugin } from "./graphql/index.js"; -import { applyThreatScanning } from "./enterprise/applyThreatScanning.js"; import type { FileManagerConfig } from "./createFileManager/types.js"; import { FileManagerFeature } from "~/features/FileManagerFeature.js"; +import { FilesPermissions as FilePermissionsImpl } from "~/createFileManager/permissions/FilesPermissions.js"; +import { SettingsPermissions as SettingsPermissionsImpl } from "~/createFileManager/permissions/SettingsPermissions.js"; +import { FilePermissions, SettingsPermissions } from "~/features/shared/abstractions.js"; export * from "./modelModifier/CmsModelModifier.js"; export * from "./plugins/index.js"; @@ -16,12 +18,27 @@ export const createFileManagerContext = ({ storageOperations }: Pick) => { const plugin = new ContextPlugin(async context => { - const fmContext = new FileManagerContextSetup(context); - context.fileManager = await fmContext.setupContext(storageOperations); - - if (context.wcp.canUseFileManagerThreatDetection()) { - context.fileManager = applyThreatScanning(context.fileManager); - } + // TODO: implements as a decorator + // if (context.wcp.canUseFileManagerThreatDetection()) { + // context.fileManager = applyThreatScanning(context.fileManager); + // } + + const identityContext = context.container.resolve(IdentityContext); + + const filePermissions = new FilePermissionsImpl({ + getIdentity: () => identityContext.getIdentity(), + getPermissions: () => identityContext.getPermissions("fm.file"), + fullAccessPermissionName: "fm.*" + }); + + const settingsPermissions = new SettingsPermissionsImpl({ + getIdentity: () => identityContext.getIdentity(), + getPermissions: () => identityContext.getPermissions("fm.settings"), + fullAccessPermissionName: "fm.*" + }); + + context.container.registerInstance(FilePermissions, filePermissions); + context.container.registerInstance(SettingsPermissions, settingsPermissions); FileManagerFeature.register(context.container); }); diff --git a/packages/api-file-manager/src/storage/FileStorage.ts b/packages/api-file-manager/src/storage/FileStorage.ts index 3bf064236c0..09c38e07182 100644 --- a/packages/api-file-manager/src/storage/FileStorage.ts +++ b/packages/api-file-manager/src/storage/FileStorage.ts @@ -1,34 +1,14 @@ +// @ts-nocheck TODO: remove this file import type { FileManagerContext } from "~/types.js"; import WebinyError from "@webiny/error"; import type { FilePhysicalStoragePlugin } from "~/plugins/FilePhysicalStoragePlugin.js"; -export type Result = Record; -const storagePluginType = "api-file-manager-storage"; +/** + * TODO: implement this via a separate service to delete file from storage. + * TODO: The service should be called as an event handled on successful file deletion from DB + */ -export interface FileStorageUploadParams { - buffer: Buffer; - hideInFileManager: boolean | string; - size: number; - name: string; - type: string; - id?: string; - key?: string; - tags?: string[]; - keyPrefix?: string; -} -export interface FileStorageDeleteParams { - id: string; - key: string; -} - -export interface FileStorageUploadMultipleParams { - files: FileStorageUploadParams[]; -} - -export interface FileStorageParams { - context: FileManagerContext; -} export class FileStorage { private readonly context: FileManagerContext; @@ -50,65 +30,4 @@ export class FileStorage { return storagePlugin; } - - async upload(params: FileStorageUploadParams): Promise { - const settings = await this.context.fileManager.getSettings(); - if (!settings) { - throw new WebinyError("Missing File Manager Settings.", "FILE_MANAGER_ERROR"); - } - - // Add file to cloud storage. - const { file: fileData } = await this.storagePlugin.upload({ - ...params, - settings - }); - - // Save file in DB. - return this.context.fileManager.createFile({ - ...fileData, - meta: { - private: Boolean(params.hideInFileManager) - }, - tags: Array.isArray(params.tags) ? params.tags : [] - }); - } - - async uploadFiles({ files }: FileStorageUploadMultipleParams) { - const settings = await this.context.fileManager.getSettings(); - if (!settings) { - throw new WebinyError("Missing File Manager Settings.", "FILE_MANAGER_ERROR"); - } - - const filesData = await Promise.all( - files.map(async item => { - // TODO: improve types of this.storagePlugin. - const { file } = await this.storagePlugin.upload({ - ...item, - settings - }); - - return { - ...file, - meta: { - private: Boolean(item.hideInFileManager) - }, - tags: Array.isArray(item.tags) ? item.tags : [] - }; - }) - ); - - return this.context.fileManager.createFilesInBatch(filesData); - } - - async delete(params: FileStorageDeleteParams) { - const { id, key } = params; - const { fileManager } = this.context; - // Delete file from cloud storage. - await this.storagePlugin.delete({ - key - }); - - // Delete file from the DB. - return await fileManager.deleteFile(id); - } } diff --git a/packages/api-file-manager/src/types.ts b/packages/api-file-manager/src/types.ts index 143b8fd79c0..776982f4bc6 100644 --- a/packages/api-file-manager/src/types.ts +++ b/packages/api-file-manager/src/types.ts @@ -108,11 +108,6 @@ export interface FileManagerSettings { srcPrefix: string; } -export interface FileManagerSystem { - version: string; - tenant: string; -} - export interface OnSettingsBeforeUpdateTopicParams { input: Partial; original: FileManagerSettings; @@ -138,165 +133,6 @@ export type SettingsCRUD = { * Storage operations *******/ -/** - * @category StorageOperations - * @category SystemStorageOperations - * @category SystemStorageOperationsParams - */ -export interface FileManagerSystemStorageOperationsUpdateParams { - /** - * The system data to be updated. - */ - original: FileManagerSystem; - /** - * The system data with the updated fields. - */ - data: FileManagerSystem; -} -/** - * @category StorageOperations - * @category SystemStorageOperations - * @category SystemStorageOperationsParams - */ -export interface FileManagerSystemStorageOperationsCreateParams { - /** - * The system fields. - */ - data: FileManagerSystem; -} - -export interface FileManagerSystemStorageOperationsGetParams { - tenant: string; -} - -/** - * @category StorageOperations - * @category SystemStorageOperations - */ -export interface FileManagerSystemStorageOperations { - /** - * Get the FileManager system data. - */ - get: (params: FileManagerSystemStorageOperationsGetParams) => Promise; - /** - * Update the FileManager system data.. - */ - update: (params: FileManagerSystemStorageOperationsUpdateParams) => Promise; - /** - * Create the FileManagerSystemData - */ - create: (params: FileManagerSystemStorageOperationsCreateParams) => Promise; -} - -/** - * @category StorageOperations - * @category SettingsStorageOperations - * @category SettingsStorageOperationsParams - */ -export interface FileManagerSettingsStorageOperationsUpdateParams { - /** - * Original settings to be updated. - */ - original: FileManagerSettings; - /** - * The settings with the updated fields. - */ - data: FileManagerSettings; -} -/** - * @category StorageOperations - * @category SettingsStorageOperations - * @category SettingsStorageOperationsParams - */ -export interface FileManagerSettingsStorageOperationsCreateParams { - /** - * The settings fields. - */ - data: FileManagerSettings; -} - -export interface FileManagerStorageOperationsGetSettingsParams { - tenant: string; -} - -export interface FileManagerStorageOperationsDeleteSettings { - tenant: string; -} - -/** - * @category StorageOperations - * @category SettingsStorageOperations - */ -export interface FileManagerSettingsStorageOperations { - /** - * Get the FileManager system data. - */ - get: ( - params: FileManagerStorageOperationsGetSettingsParams - ) => Promise; - /** - * Create the FileManagerSettingsData - */ - create: ( - params: FileManagerSettingsStorageOperationsCreateParams - ) => Promise; - /** - * Update the FileManager system data.. - */ - update: ( - params: FileManagerSettingsStorageOperationsUpdateParams - ) => Promise; - /** - * Delete the existing settings. - */ - delete: (params: FileManagerStorageOperationsDeleteSettings) => Promise; -} - -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsGetParams { - where: { - id: string; - tenant: string; - }; -} -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsCreateParams { - file: File; -} -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsUpdateParams { - original: File; - file: File; -} - -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsDeleteParams { - file: File; -} -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsCreateBatchParams { - files: File[]; -} /** * @category StorageOperations * @category FilesStorageOperations @@ -305,99 +141,16 @@ export interface FileManagerFilesStorageOperationsCreateBatchParams { export interface FileManagerFilesStorageOperationsListParamsWhere { [key: string]: any; } -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsListParams { - where: FileManagerFilesStorageOperationsListParamsWhere; - sort: CmsEntryListSort; - limit: number; - after: string | null; - search?: string; -} - -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsListResponseMeta { - hasMoreItems: boolean; - totalCount: number; - cursor: string | null; -} -export type FileManagerFilesStorageOperationsListResponse = [ - File[], - FileManagerFilesStorageOperationsListResponseMeta -]; - -export interface FileManagerFilesStorageOperationsTagsResponse { - tag: string; - count: number; -} export interface FileManagerFilesStorageOperationsTagsParamsWhere extends FilesCrudListTagsWhere { tenant: string; } -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsTagsParams { - where: FileManagerFilesStorageOperationsTagsParamsWhere; - limit: number; - after?: string; -} -/** - * @category StorageOperations - * @category FilesStorageOperations - */ -export interface FileManagerFilesStorageOperations { - /** - * Get a single file with given ID from the storage. - */ - get: (params: FileManagerFilesStorageOperationsGetParams) => Promise; - /** - * Insert the file data into the database. - */ - create: (params: FileManagerFilesStorageOperationsCreateParams) => Promise; - /** - * Update the file data in the database. - */ - update: (params: FileManagerFilesStorageOperationsUpdateParams) => Promise; - /** - * Delete the file from the database. - */ - delete: (params: FileManagerFilesStorageOperationsDeleteParams) => Promise; - /** - * Store multiple files at once to the database. - */ - createBatch: (params: FileManagerFilesStorageOperationsCreateBatchParams) => Promise; - /** - * Get a list of files filtered by given parameters. - */ - list: ( - params: FileManagerFilesStorageOperationsListParams - ) => Promise; - /** - * Get a list of all file tags filtered by given parameters. - */ - tags: ( - params: FileManagerFilesStorageOperationsTagsParams - ) => Promise; -} -export interface FileManagerAliasesStorageOperations { +export interface FileAliasesStorageOperations { storeAliases(file: File): Promise; deleteAliases(file: File): Promise; } -export interface FileManagerStorageOperations { - beforeInit?: (context: TContext) => Promise; - files: FileManagerFilesStorageOperations; - aliases: FileManagerAliasesStorageOperations; - settings: FileManagerSettingsStorageOperations; +export interface FileManagerStorageOperations { + aliases: FileAliasesStorageOperations; } diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 8e4032ceeae..afad0e557f0 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -549,20 +549,6 @@ export interface CmsEntry { */ lastPublishedBy: CmsIdentity | null; - /** - * Deprecated fields. 👇 - */ - - /** - * @deprecated Will be removed with the 5.41.0 release. Use `createdBy` field instead. - */ - ownedBy?: CmsIdentity | null; - - /** - * @deprecated Will be removed with the 5.41.0 release. Use `firstPublishedOn` or `lastPublishedOn` field instead. - */ - publishedOn?: string | null; - /** * Model ID of the definition for the entry. * @see CmsModel From 44a0786b58758db4d2db85a7ee891763f7d55448 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 18:36:52 +0100 Subject: [PATCH 54/71] wip: migrate file manager --- .../api-file-manager/src/domain/file/types.ts | 4 -- .../file/UpdateFile/UpdateFileRepository.ts | 47 ++++++++++++ .../file/UpdateFile/UpdateFileUseCase.ts | 71 +++++++++++++++++++ .../features/file/UpdateFile/abstractions.ts | 61 ++++++++++++++++ .../src/features/file/UpdateFile/events.ts | 57 +++++++++++++++ .../src/features/file/UpdateFile/feature.ts | 11 +++ .../features/file/shared/FileToEntryMapper.ts | 2 - 7 files changed, 247 insertions(+), 6 deletions(-) create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/events.ts create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/feature.ts diff --git a/packages/api-file-manager/src/domain/file/types.ts b/packages/api-file-manager/src/domain/file/types.ts index cf13aa5c774..52aa22e0cc8 100644 --- a/packages/api-file-manager/src/domain/file/types.ts +++ b/packages/api-file-manager/src/domain/file/types.ts @@ -27,7 +27,6 @@ export interface File { }; tags: string[]; aliases: string[]; - createdOn: string; modifiedOn: string | null; savedOn: string; @@ -35,9 +34,6 @@ export interface File { modifiedBy: CreatedBy | null; savedBy: CreatedBy; extensions?: Record; - - tenant: string; - webinyVersion: string; } export interface FileAlias { diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts new file mode 100644 index 00000000000..147526808ce --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts @@ -0,0 +1,47 @@ +import { Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { UpdateFileRepository as RepositoryAbstraction } from "./abstractions.js"; +import { FileModel } from "~/domain/file/abstractions.js"; +import type { File } from "~/domain/file/types.js"; +import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; +import { FileToEntryMapper } from "../shared/FileToEntryMapper.js"; + +class UpdateFileRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async update(file: File): Promise> { + const entry = FileToEntryMapper.toEntry(file); + + const valuesToUpdate = { + wbyAco_location: file.location, + ...entry.values + }; + + // Files are not versioned, so we're always updating the same revision + const id = `${file.id}#0001`; + + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.updateEntry.execute(this.fileModel, id, valuesToUpdate); + }); + + if (result.isFail()) { + const error = result.error; + if (error.code === "Cms/Entry/NotFound") { + return Result.fail(new FileNotFoundError(id)); + } + return Result.fail(new FilePersistenceError(result.error)); + } + + return Result.ok(); + } +} + +export const UpdateFileRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateFileRepositoryImpl, + dependencies: [UpdateEntryUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts new file mode 100644 index 00000000000..44699029df8 --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts @@ -0,0 +1,71 @@ +import { Result } from "@webiny/feature/api"; +import { + UpdateFileUseCase as UseCaseAbstraction, + UpdateFileInput, + UpdateFileRepository +} from "./abstractions.js"; +import { GetFileUseCase } from "../GetFile/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { File } from "~/domain/file/types.js"; +import { FileNotAuthorizedError } from "~/domain/file/errors.js"; +import { FileBeforeUpdateEvent, FileAfterUpdateEvent } from "./events.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; + +class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private getFile: GetFileUseCase.Interface, + private repository: UpdateFileRepository.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute(input: UpdateFileInput): Promise> { + // Check write permission + const hasPermission = await this.filePermissions.ensure({ rwd: "w" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + // Get original file (includes ownership check) + const getResult = await this.getFile.execute({ id: input.id }); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + + const original = getResult.value; + + // Build updated file for event + const file: File = { + ...original, + ...input, + // Preserve immutable fields + id: original.id, + key: original.key, + size: original.size, + type: original.type, + createdOn: original.createdOn, + createdBy: original.createdBy, + // Update mutable fields + tags: input.tags !== undefined ? input.tags : original.tags, + aliases: input.aliases !== undefined ? input.aliases : original.aliases, + location: input.location !== undefined ? input.location : original.location + }; + + await this.eventPublisher.publish(new FileBeforeUpdateEvent({ original, file, input })); + + const result = await this.repository.update(file); + + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish(new FileAfterUpdateEvent({ original, file, input })); + + return Result.ok(file); + } +} + +export const UpdateFileUseCase = UseCaseAbstraction.createImplementation({ + implementation: UpdateFileUseCaseImpl, + dependencies: [FilePermissions, GetFileUseCase, UpdateFileRepository, EventPublisher] +}); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts new file mode 100644 index 00000000000..ac42613783c --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts @@ -0,0 +1,61 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { File } from "~/domain/file/types.js"; +import { + type FilePersistenceError, + type FileNotFoundError, + FileNotAuthorizedError +} from "~/domain/file/errors.js"; + +export interface UpdateFileInput { + id: string; + name?: string; + meta?: Record; + tags?: string[]; + location?: { folderId: string }; + aliases?: string[]; +} + +/** + * UpdateFile repository interface + */ +export interface IUpdateFileRepository { + update(file: File): Promise>; +} + +export interface IUpdateFileRepositoryErrors { + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type RepositoryError = IUpdateFileRepositoryErrors[keyof IUpdateFileRepositoryErrors]; + +export const UpdateFileRepository = + createAbstraction("UpdateFileRepository"); + +export namespace UpdateFileRepository { + export type Interface = IUpdateFileRepository; + export type Error = RepositoryError; +} + +/** + * UpdateFile use case interface + */ +export interface IUpdateFileUseCase { + execute(input: UpdateFileInput): Promise>; +} + +export interface IUpdateFileUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type UseCaseError = IUpdateFileUseCaseErrors[keyof IUpdateFileUseCaseErrors]; + +export const UpdateFileUseCase = createAbstraction("UpdateFileUseCase"); + +export namespace UpdateFileUseCase { + export type Interface = IUpdateFileUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/UpdateFile/events.ts b/packages/api-file-manager/src/features/file/UpdateFile/events.ts new file mode 100644 index 00000000000..d17355ef15e --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/events.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { File } from "~/domain/file/types.js"; +import type { UpdateFileInput } from "./abstractions.js"; + +// ============================================================================ +// FileBeforeUpdate Event +// ============================================================================ + +export interface FileBeforeUpdatePayload { + original: File; + file: File; + input: UpdateFileInput; +} + +export class FileBeforeUpdateEvent extends DomainEvent { + eventType = "FileManager/File/BeforeUpdate" as const; + + getHandlerAbstraction() { + return FileBeforeUpdateHandler; + } +} + +export const FileBeforeUpdateHandler = + createAbstraction>("FileBeforeUpdateHandler"); + +export namespace FileBeforeUpdateHandler { + export type Interface = IEventHandler; + export type Event = FileBeforeUpdateEvent; +} + +// ============================================================================ +// FileAfterUpdate Event +// ============================================================================ + +export interface FileAfterUpdatePayload { + original: File; + file: File; + input: UpdateFileInput; +} + +export class FileAfterUpdateEvent extends DomainEvent { + eventType = "FileManager/File/AfterUpdate" as const; + + getHandlerAbstraction() { + return FileAfterUpdateHandler; + } +} + +export const FileAfterUpdateHandler = + createAbstraction>("FileAfterUpdateHandler"); + +export namespace FileAfterUpdateHandler { + export type Interface = IEventHandler; + export type Event = FileAfterUpdateEvent; +} diff --git a/packages/api-file-manager/src/features/file/UpdateFile/feature.ts b/packages/api-file-manager/src/features/file/UpdateFile/feature.ts new file mode 100644 index 00000000000..838cea28387 --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { UpdateFileRepository } from "./UpdateFileRepository.js"; +import { UpdateFileUseCase } from "./UpdateFileUseCase.js"; + +export const UpdateFileFeature = createFeature({ + name: "FileManager.UpdateFile", + register(container) { + container.register(UpdateFileUseCase); + container.register(UpdateFileRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts b/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts index a4eaaae3b23..23edf9513d4 100644 --- a/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts +++ b/packages/api-file-manager/src/features/file/shared/FileToEntryMapper.ts @@ -13,7 +13,6 @@ export class FileToEntryMapper { modifiedBy: file.modifiedBy, savedBy: file.savedBy, location: file.location || { folderId: "root" }, - webinyVersion: file.webinyVersion, values: { name: file.name, key: file.key, @@ -23,7 +22,6 @@ export class FileToEntryMapper { accessControl: file.accessControl, tags: file.tags || [], aliases: file.aliases || [], - tenant: file.tenant, extensions: file.extensions } }; From e831f146dbeb1fefa6445c36e0cd47f6204db8fe Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 18:49:35 +0100 Subject: [PATCH 55/71] wip: migrate file manager --- .../src/features/file/CreateFile/feature.ts | 2 +- .../file/DeleteFile/DeleteFileRepository.ts | 39 +++++++++++++ .../file/DeleteFile/DeleteFileUseCase.ts | 53 ++++++++++++++++++ .../features/file/DeleteFile/abstractions.ts | 55 +++++++++++++++++++ .../src/features/file/DeleteFile/events.ts | 52 ++++++++++++++++++ .../src/features/file/DeleteFile/feature.ts | 11 ++++ .../src/features/file/GetFile/feature.ts | 2 +- .../src/features/file/UpdateFile/feature.ts | 2 +- 8 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/events.ts create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/feature.ts diff --git a/packages/api-file-manager/src/features/file/CreateFile/feature.ts b/packages/api-file-manager/src/features/file/CreateFile/feature.ts index 895c2c54f3e..036925c70f8 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/feature.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/feature.ts @@ -3,7 +3,7 @@ import { CreateFileRepository } from "./CreateFileRepository.js"; import { CreateFileUseCase } from "./CreateFileUseCase.js"; export const CreateFileFeature = createFeature({ - name: "FileManager.CreateFile", + name: "FileManager/CreateFile", register(container) { container.register(CreateFileUseCase); container.register(CreateFileRepository).inSingletonScope(); diff --git a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts new file mode 100644 index 00000000000..0db3bb48bc4 --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts @@ -0,0 +1,39 @@ +import { Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { DeleteFileRepository as RepositoryAbstraction } from "./abstractions.js"; +import { FileModel } from "~/domain/file/abstractions.js"; +import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; +import { File } from "~/domain/file/types.js"; + +class DeleteFileRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private deleteEntry: DeleteEntryUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async delete(file: File): Promise> { + // Files are not versioned, so we're always deleting the same revision + const entryId = `${file.id}#0001`; + + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.deleteEntry.execute(this.fileModel, entryId); + }); + + if (result.isFail()) { + const error = result.error; + if (error.code === "Cms/Entry/NotFound") { + return Result.fail(new FileNotFoundError(file.id)); + } + return Result.fail(new FilePersistenceError(result.error)); + } + + return Result.ok(); + } +} + +export const DeleteFileRepository = RepositoryAbstraction.createImplementation({ + implementation: DeleteFileRepositoryImpl, + dependencies: [DeleteEntryUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts new file mode 100644 index 00000000000..5086b0be39e --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { + DeleteFileUseCase as UseCaseAbstraction, + DeleteFileInput, + DeleteFileRepository +} from "./abstractions.js"; +import { GetFileUseCase } from "../GetFile/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { FileNotAuthorizedError } from "~/domain/file/errors.js"; +import { FileBeforeDeleteEvent, FileAfterDeleteEvent } from "./events.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; + +class DeleteFileUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private getFile: GetFileUseCase.Interface, + private repository: DeleteFileRepository.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute(input: DeleteFileInput): Promise> { + // Check delete permission + const hasPermission = await this.filePermissions.ensure({ rwd: "d" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + // Get file (includes ownership check) + const getResult = await this.getFile.execute({ id: input.id }); + if (getResult.isFail()) { + return Result.fail(getResult.error); + } + + const file = getResult.value; + + await this.eventPublisher.publish(new FileBeforeDeleteEvent({ file })); + + const result = await this.repository.delete(file); + + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish(new FileAfterDeleteEvent({ file })); + + return Result.ok(); + } +} + +export const DeleteFileUseCase = UseCaseAbstraction.createImplementation({ + implementation: DeleteFileUseCaseImpl, + dependencies: [FilePermissions, GetFileUseCase, DeleteFileRepository, EventPublisher] +}); diff --git a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts new file mode 100644 index 00000000000..45e9561cb99 --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import { + type FilePersistenceError, + type FileNotFoundError, + FileNotAuthorizedError +} from "~/domain/file/errors.js"; +import { File } from "~/domain/file/types.js"; + +export interface DeleteFileInput { + id: string; +} + +/** + * DeleteFile repository interface + */ +export interface IDeleteFileRepository { + delete(file: File): Promise>; +} + +export interface IDeleteFileRepositoryErrors { + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type RepositoryError = IDeleteFileRepositoryErrors[keyof IDeleteFileRepositoryErrors]; + +export const DeleteFileRepository = createAbstraction("DeleteFileRepository"); + +export namespace DeleteFileRepository { + export type Interface = IDeleteFileRepository; + export type Error = RepositoryError; +} + +/** + * DeleteFile use case interface + */ +export interface IDeleteFileUseCase { + execute(input: DeleteFileInput): Promise>; +} + +export interface IDeleteFileUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + notFound: FileNotFoundError; + persistence: FilePersistenceError; +} + +type UseCaseError = IDeleteFileUseCaseErrors[keyof IDeleteFileUseCaseErrors]; + +export const DeleteFileUseCase = createAbstraction("DeleteFileUseCase"); + +export namespace DeleteFileUseCase { + export type Interface = IDeleteFileUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/DeleteFile/events.ts b/packages/api-file-manager/src/features/file/DeleteFile/events.ts new file mode 100644 index 00000000000..e26780f5a60 --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/events.ts @@ -0,0 +1,52 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { File } from "~/domain/file/types.js"; + +// ============================================================================ +// FileBeforeDelete Event +// ============================================================================ + +export interface FileBeforeDeletePayload { + file: File; +} + +export class FileBeforeDeleteEvent extends DomainEvent { + eventType = "FileManager/File/BeforeDelete" as const; + + getHandlerAbstraction() { + return FileBeforeDeleteHandler; + } +} + +export const FileBeforeDeleteHandler = + createAbstraction>("FileBeforeDeleteHandler"); + +export namespace FileBeforeDeleteHandler { + export type Interface = IEventHandler; + export type Event = FileBeforeDeleteEvent; +} + +// ============================================================================ +// FileAfterDelete Event +// ============================================================================ + +export interface FileAfterDeletePayload { + file: File; +} + +export class FileAfterDeleteEvent extends DomainEvent { + eventType = "FileManager/File/AfterDelete" as const; + + getHandlerAbstraction() { + return FileAfterDeleteHandler; + } +} + +export const FileAfterDeleteHandler = + createAbstraction>("FileAfterDeleteHandler"); + +export namespace FileAfterDeleteHandler { + export type Interface = IEventHandler; + export type Event = FileAfterDeleteEvent; +} diff --git a/packages/api-file-manager/src/features/file/DeleteFile/feature.ts b/packages/api-file-manager/src/features/file/DeleteFile/feature.ts new file mode 100644 index 00000000000..9f15b2e4556 --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteFileRepository } from "./DeleteFileRepository.js"; +import { DeleteFileUseCase } from "./DeleteFileUseCase.js"; + +export const DeleteFileFeature = createFeature({ + name: "FileManager/DeleteFile", + register(container) { + container.register(DeleteFileUseCase); + container.register(DeleteFileRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/GetFile/feature.ts b/packages/api-file-manager/src/features/file/GetFile/feature.ts index eeae0339535..777769a997a 100644 --- a/packages/api-file-manager/src/features/file/GetFile/feature.ts +++ b/packages/api-file-manager/src/features/file/GetFile/feature.ts @@ -3,7 +3,7 @@ import { GetFileRepository } from "./GetFileRepository.js"; import { GetFileUseCase } from "./GetFileUseCase.js"; export const GetFileFeature = createFeature({ - name: "FileManager.GetFile", + name: "FileManager/GetFile", register(container) { container.register(GetFileUseCase); container.register(GetFileRepository).inSingletonScope(); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/feature.ts b/packages/api-file-manager/src/features/file/UpdateFile/feature.ts index 838cea28387..c1b86c0e165 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/feature.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/feature.ts @@ -3,7 +3,7 @@ import { UpdateFileRepository } from "./UpdateFileRepository.js"; import { UpdateFileUseCase } from "./UpdateFileUseCase.js"; export const UpdateFileFeature = createFeature({ - name: "FileManager.UpdateFile", + name: "FileManager/UpdateFile", register(container) { container.register(UpdateFileUseCase); container.register(UpdateFileRepository).inSingletonScope(); From f9d6f7af50c3094fa8bdb77dadd9252f47539fba Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 19:27:17 +0100 Subject: [PATCH 56/71] wip: migrate file manager --- .../file/CreateFile/CreateFileRepository.ts | 2 +- .../file/CreateFile/CreateFileUseCase.ts | 2 +- .../features/file/CreateFile/abstractions.ts | 2 +- .../CreateFilesInBatchRepository.ts | 33 ++++++ .../CreateFilesInBatchUseCase.ts | 109 ++++++++++++++++++ .../file/CreateFilesInBatch/abstractions.ts | 59 ++++++++++ .../file/CreateFilesInBatch/events.ts | 56 +++++++++ .../file/CreateFilesInBatch/feature.ts | 11 ++ .../features/file/CreateFilesInBatch/index.ts | 1 + .../src/features/file/DeleteFile/index.ts | 1 + .../src/features/file/UpdateFile/index.ts | 1 + .../features/file/shared/EntryToFileMapper.ts | 2 - 12 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/events.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/feature.ts create mode 100644 packages/api-file-manager/src/features/file/CreateFilesInBatch/index.ts create mode 100644 packages/api-file-manager/src/features/file/DeleteFile/index.ts create mode 100644 packages/api-file-manager/src/features/file/UpdateFile/index.ts diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts index 0367b955798..cc360602256 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts @@ -14,7 +14,7 @@ class CreateFileRepositoryImpl implements RepositoryAbstraction.Interface { private identityContext: IdentityContext.Interface ) {} - async create(data: FileInput): Promise> { + async execute(data: FileInput): Promise> { const result = await this.identityContext.withoutAuthorization(async () => { return await this.createEntry.execute(this.fileModel, data); }); diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts index 1fb584a4662..05a478d178c 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts @@ -51,7 +51,7 @@ class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(new FileBeforeCreateEvent({ file: fileInput, meta })); - const result = await this.repository.create(fileInput); + const result = await this.repository.execute(fileInput); if (result.isFail()) { return Result.fail(result.error); diff --git a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts index c24d9ff8185..ae03a187e02 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts @@ -24,7 +24,7 @@ export interface CreateFileInput { * CreateFile repository interface */ export interface ICreateFileRepository { - create(data: FileInput): Promise>; + execute(data: FileInput): Promise>; } export interface ICreateFileRepositoryErrors { diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts new file mode 100644 index 00000000000..4caec15e88f --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts @@ -0,0 +1,33 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { CreateFilesInBatchRepository as RepositoryAbstraction } from "./abstractions.js"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { CreateFileRepository } from "~/features/file/CreateFile/abstractions.js"; + +class CreateFilesInBatchRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private createFileRepository: CreateFileRepository.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async createBatch(files: FileInput[]): Promise> { + const results = await this.identityContext.withoutAuthorization(async () => { + return await Promise.all( + files.map(async input => { + return this.createFileRepository.execute(input); + }) + ); + }); + + // Return only successful results. + // TODO: group files into successful and failed + const createdFiles = results.filter(result => result.isOk()).map(result => result.value); + + return Result.ok(createdFiles); + } +} + +export const CreateFilesInBatchRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateFilesInBatchRepositoryImpl, + dependencies: [CreateFileRepository, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts new file mode 100644 index 00000000000..8a18a12cec9 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts @@ -0,0 +1,109 @@ +import { Result } from "@webiny/feature/api"; +import { + CreateFilesInBatchUseCase as UseCaseAbstraction, + CreateFilesInBatchInput, + CreateFilesInBatchRepository +} from "./abstractions.js"; +import { GetSettingsUseCase } from "../../settings/GetSettings/abstractions.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { FileNotAuthorizedError, InvalidFileSizeError } from "~/domain/file/errors.js"; +import { FileBeforeBatchCreateEvent, FileAfterBatchCreateEvent } from "./events.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; +import { CreateFileInput } from "~/features/file/CreateFile/abstractions.js"; + +class CreateFilesInBatchUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private repository: CreateFilesInBatchRepository.Interface, + private getSettings: GetSettingsUseCase.Interface, + private eventPublisher: EventPublisher.Interface + ) {} + + async execute( + input: CreateFilesInBatchInput + ): Promise> { + // Check write permission + const hasPermission = await this.filePermissions.ensure({ rwd: "w" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + // Validate all files + const validationResult = await this.validateInput(input.files); + if (validationResult.isFail()) { + return Result.fail(validationResult.error); + } + + // Prepare file inputs with defaults + const fileInputs: FileInput[] = input.files.map(file => { + const [id] = file.key.split("/"); + return { + id: file.id || id, + key: file.key, + name: file.name, + size: file.size, + type: file.type, + meta: file.meta || {}, + location: file.location || { folderId: "root" }, + tags: file.tags || [], + aliases: file.aliases || [], + extensions: input.meta || {} + }; + }); + + await this.eventPublisher.publish( + new FileBeforeBatchCreateEvent({ files: fileInputs, meta: input.meta }) + ); + + const result = await this.repository.createBatch(fileInputs); + + if (result.isFail()) { + return Result.fail(result.error); + } + + await this.eventPublisher.publish( + new FileAfterBatchCreateEvent({ files: result.value, meta: input.meta }) + ); + + return Result.ok(result.value); + } + + private async validateInput( + files: CreateFileInput[] + ): Promise> { + const settingsResult = await this.getSettings.execute(); + + const settings = settingsResult.value; + + if (settings) { + for (const input of files) { + // Validate file size + if ( + input.size < settings.uploadMinFileSize || + input.size > settings.uploadMaxFileSize + ) { + return Result.fail( + new InvalidFileSizeError({ + size: input.size, + minSize: settings.uploadMinFileSize, + maxSize: settings.uploadMaxFileSize + }) + ); + } + } + } + + return Result.ok(); + } +} + +export const CreateFilesInBatchUseCase = UseCaseAbstraction.createImplementation({ + implementation: CreateFilesInBatchUseCaseImpl, + dependencies: [ + FilePermissions, + CreateFilesInBatchRepository, + GetSettingsUseCase, + EventPublisher + ] +}); diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts new file mode 100644 index 00000000000..4302f6841e1 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts @@ -0,0 +1,59 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { File, FileInput } from "~/domain/file/types.js"; +import { + type FilePersistenceError, + type InvalidFileSizeError, + FileNotAuthorizedError +} from "~/domain/file/errors.js"; + +export interface CreateFilesInBatchInput { + files: FileInput[]; + meta?: Record; +} + +/** + * CreateFilesInBatch repository interface + */ +export interface ICreateFilesInBatchRepository { + createBatch(files: FileInput[]): Promise>; +} + +export interface ICreateFilesInBatchRepositoryErrors { + persistence: FilePersistenceError; +} + +type RepositoryError = ICreateFilesInBatchRepositoryErrors[keyof ICreateFilesInBatchRepositoryErrors]; + +export const CreateFilesInBatchRepository = createAbstraction( + "CreateFilesInBatchRepository" +); + +export namespace CreateFilesInBatchRepository { + export type Interface = ICreateFilesInBatchRepository; + export type Error = RepositoryError; +} + +/** + * CreateFilesInBatch use case interface + */ +export interface ICreateFilesInBatchUseCase { + execute(input: CreateFilesInBatchInput): Promise>; +} + +export interface ICreateFilesInBatchUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + persistence: FilePersistenceError; + invalidSize: InvalidFileSizeError; +} + +type UseCaseError = ICreateFilesInBatchUseCaseErrors[keyof ICreateFilesInBatchUseCaseErrors]; + +export const CreateFilesInBatchUseCase = createAbstraction( + "CreateFilesInBatchUseCase" +); + +export namespace CreateFilesInBatchUseCase { + export type Interface = ICreateFilesInBatchUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/events.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/events.ts new file mode 100644 index 00000000000..7cc036da1d3 --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/events.ts @@ -0,0 +1,56 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { File, FileInput } from "~/domain/file/types.js"; + +// ============================================================================ +// FileBeforeBatchCreate Event +// ============================================================================ + +export interface FileBeforeBatchCreatePayload { + files: FileInput[]; + meta?: Record; +} + +export class FileBeforeBatchCreateEvent extends DomainEvent { + eventType = "FileManager/File/BeforeBatchCreate" as const; + + getHandlerAbstraction() { + return FileBeforeBatchCreateHandler; + } +} + +export const FileBeforeBatchCreateHandler = createAbstraction< + IEventHandler +>("FileBeforeBatchCreateHandler"); + +export namespace FileBeforeBatchCreateHandler { + export type Interface = IEventHandler; + export type Event = FileBeforeBatchCreateEvent; +} + +// ============================================================================ +// FileAfterBatchCreate Event +// ============================================================================ + +export interface FileAfterBatchCreatePayload { + files: File[]; + meta?: Record; +} + +export class FileAfterBatchCreateEvent extends DomainEvent { + eventType = "FileManager/File/AfterBatchCreate" as const; + + getHandlerAbstraction() { + return FileAfterBatchCreateHandler; + } +} + +export const FileAfterBatchCreateHandler = createAbstraction< + IEventHandler +>("FileAfterBatchCreateHandler"); + +export namespace FileAfterBatchCreateHandler { + export type Interface = IEventHandler; + export type Event = FileAfterBatchCreateEvent; +} diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/feature.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/feature.ts new file mode 100644 index 00000000000..b07751318ca --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateFilesInBatchRepository } from "./CreateFilesInBatchRepository.js"; +import { CreateFilesInBatchUseCase } from "./CreateFilesInBatchUseCase.js"; + +export const CreateFilesInBatchFeature = createFeature({ + name: "FileManager/CreateFilesInBatch", + register(container) { + container.register(CreateFilesInBatchUseCase); + container.register(CreateFilesInBatchRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/index.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/index.ts new file mode 100644 index 00000000000..ba6cb25c6aa --- /dev/null +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/index.ts @@ -0,0 +1 @@ +export { CreateFilesInBatchUseCase } from "./abstractions.js"; diff --git a/packages/api-file-manager/src/features/file/DeleteFile/index.ts b/packages/api-file-manager/src/features/file/DeleteFile/index.ts new file mode 100644 index 00000000000..0131ad0f5b4 --- /dev/null +++ b/packages/api-file-manager/src/features/file/DeleteFile/index.ts @@ -0,0 +1 @@ +export { DeleteFileUseCase } from "./abstractions.js"; diff --git a/packages/api-file-manager/src/features/file/UpdateFile/index.ts b/packages/api-file-manager/src/features/file/UpdateFile/index.ts new file mode 100644 index 00000000000..ec9a0c537a2 --- /dev/null +++ b/packages/api-file-manager/src/features/file/UpdateFile/index.ts @@ -0,0 +1 @@ +export { UpdateFileUseCase } from "./abstractions.js"; diff --git a/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts index 5259f4890d1..e75593c9c0e 100644 --- a/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts +++ b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts @@ -20,8 +20,6 @@ export class EntryToFileMapper { location: entry.values.location || { folderId: "root" }, tags: entry.values.tags || [], aliases: entry.values.aliases || [], - tenant: entry.tenant, - webinyVersion: entry.webinyVersion, extensions: entry.values.extensions }; } From 9baa4c42c7d0713d109bbe0ae56dc7f4b7ce9ce4 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 19:42:19 +0100 Subject: [PATCH 57/71] wip: migrate file manager --- .../file/ListFiles/ListFilesRepository.ts | 51 ++++++++++++++++ .../file/ListFiles/ListFilesUseCase.ts | 57 ++++++++++++++++++ .../features/file/ListFiles/abstractions.ts | 59 +++++++++++++++++++ .../src/features/file/ListFiles/feature.ts | 11 ++++ .../src/features/file/ListFiles/index.ts | 1 + 5 files changed, 179 insertions(+) create mode 100644 packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts create mode 100644 packages/api-file-manager/src/features/file/ListFiles/ListFilesUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/ListFiles/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/ListFiles/feature.ts create mode 100644 packages/api-file-manager/src/features/file/ListFiles/index.ts diff --git a/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts b/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts new file mode 100644 index 00000000000..601641ccf40 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts @@ -0,0 +1,51 @@ +import { Result } from "@webiny/feature/api"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/index.js"; +import { + ListFilesRepository as RepositoryAbstraction, + ListFilesInput, + ListFilesOutput +} from "./abstractions.js"; +import { FileModel } from "~/domain/file/abstractions.js"; +import { FilePersistenceError } from "~/domain/file/errors.js"; +import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; + +class ListFilesRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute( + input: ListFilesInput + ): Promise> { + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.listLatestEntries.execute(this.fileModel, { + where: input.where || {}, + limit: input.limit || 40, + after: input.after || undefined, + sort: input.sort || ["id_DESC"], + search: input.search + }); + }); + + if (result.isFail()) { + return Result.fail(new FilePersistenceError(result.error)); + } + + const [items, meta] = result.value; + + const files = items.map(entry => EntryToFileMapper.toFile(entry)); + + return Result.ok({ + items: files, + meta + }); + } +} + +export const ListFilesRepository = RepositoryAbstraction.createImplementation({ + implementation: ListFilesRepositoryImpl, + dependencies: [ListLatestEntriesUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/ListFiles/ListFilesUseCase.ts b/packages/api-file-manager/src/features/file/ListFiles/ListFilesUseCase.ts new file mode 100644 index 00000000000..9ac07296cc5 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListFiles/ListFilesUseCase.ts @@ -0,0 +1,57 @@ +import { Result } from "@webiny/feature/api"; +import { + ListFilesUseCase as UseCaseAbstraction, + ListFilesInput, + ListFilesOutput, + ListFilesRepository +} from "./abstractions.js"; +import { FileNotAuthorizedError } from "~/domain/file/errors.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; + +class ListFilesUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private identityContext: IdentityContext.Interface, + private repository: ListFilesRepository.Interface + ) {} + + async execute( + input: ListFilesInput + ): Promise> { + // Check read permission + const hasPermission = await this.filePermissions.ensure({ rwd: "r" }); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + // Build where clause + const where: ListFilesInput["where"] = { + ...(input.where || {}) + }; + + // Filter by createdBy if user can only access own records + if (await this.filePermissions.canAccessOnlyOwnRecords()) { + const identity = this.identityContext.getIdentity(); + where.createdBy = identity.id; + } + + const result = await this.repository.execute({ + ...input, + where, + limit: input.limit || 40, + sort: input.sort && input.sort.length > 0 ? input.sort : ["id_DESC"] + }); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const ListFilesUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListFilesUseCaseImpl, + dependencies: [FilePermissions, IdentityContext, ListFilesRepository] +}); diff --git a/packages/api-file-manager/src/features/file/ListFiles/abstractions.ts b/packages/api-file-manager/src/features/file/ListFiles/abstractions.ts new file mode 100644 index 00000000000..52beed8b4fc --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListFiles/abstractions.ts @@ -0,0 +1,59 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import type { File } from "~/domain/file/types.js"; +import { type FilePersistenceError, FileNotAuthorizedError } from "~/domain/file/errors.js"; +import type { CmsEntryMeta } from "@webiny/api-headless-cms/types"; + +export interface ListFilesInput { + limit?: number; + after?: string | null; + where?: Record; + sort?: Array<`${string}_ASC` | `${string}_DESC`>; + search?: string; +} + +export interface ListFilesOutput { + items: File[]; + meta: CmsEntryMeta; +} + +/** + * ListFiles repository interface + */ +export interface IListFilesRepository { + execute(input: ListFilesInput): Promise>; +} + +export interface IListFilesRepositoryErrors { + persistence: FilePersistenceError; +} + +type RepositoryError = IListFilesRepositoryErrors[keyof IListFilesRepositoryErrors]; + +export const ListFilesRepository = createAbstraction("ListFilesRepository"); + +export namespace ListFilesRepository { + export type Interface = IListFilesRepository; + export type Error = RepositoryError; +} + +/** + * ListFiles use case interface + */ +export interface IListFilesUseCase { + execute(input: ListFilesInput): Promise>; +} + +export interface IListFilesUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + persistence: FilePersistenceError; +} + +type UseCaseError = IListFilesUseCaseErrors[keyof IListFilesUseCaseErrors]; + +export const ListFilesUseCase = createAbstraction("ListFilesUseCase"); + +export namespace ListFilesUseCase { + export type Interface = IListFilesUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/ListFiles/feature.ts b/packages/api-file-manager/src/features/file/ListFiles/feature.ts new file mode 100644 index 00000000000..4b127db9562 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListFiles/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListFilesRepository } from "./ListFilesRepository.js"; +import { ListFilesUseCase } from "./ListFilesUseCase.js"; + +export const ListFilesFeature = createFeature({ + name: "FileManager/ListFiles", + register(container) { + container.register(ListFilesUseCase); + container.register(ListFilesRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/ListFiles/index.ts b/packages/api-file-manager/src/features/file/ListFiles/index.ts new file mode 100644 index 00000000000..7c0b917d97c --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListFiles/index.ts @@ -0,0 +1 @@ +export { ListFilesUseCase } from "./abstractions.js"; From 445d65c0e2961cbad8251a1205b387a7fde527bd Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Fri, 21 Nov 2025 20:17:49 +0100 Subject: [PATCH 58/71] wip: migrate file manager --- .../file/ListTags/ListTagsRepository.ts | 48 ++++++++++++++++ .../features/file/ListTags/ListTagsUseCase.ts | 42 ++++++++++++++ .../features/file/ListTags/abstractions.ts | 55 +++++++++++++++++++ .../src/features/file/ListTags/feature.ts | 11 ++++ .../src/features/file/ListTags/index.ts | 1 + 5 files changed, 157 insertions(+) create mode 100644 packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts create mode 100644 packages/api-file-manager/src/features/file/ListTags/ListTagsUseCase.ts create mode 100644 packages/api-file-manager/src/features/file/ListTags/abstractions.ts create mode 100644 packages/api-file-manager/src/features/file/ListTags/feature.ts create mode 100644 packages/api-file-manager/src/features/file/ListTags/index.ts diff --git a/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts new file mode 100644 index 00000000000..f65e40c6657 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts @@ -0,0 +1,48 @@ +import { Result } from "@webiny/feature/api"; +import { GetUniqueFieldValuesUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetUniqueFieldValues"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { ListTagsRepository as RepositoryAbstraction, ListTagsInput, TagItem } from "./abstractions.js"; +import { FileModel } from "~/domain/file/abstractions.js"; +import { FilePersistenceError } from "~/domain/file/errors.js"; + +class ListTagsRepositoryImpl implements RepositoryAbstraction.Interface { + constructor( + private getUniqueFieldValues: GetUniqueFieldValuesUseCase.Interface, + private fileModel: FileModel.Interface, + private identityContext: IdentityContext.Interface + ) {} + + async execute(input: ListTagsInput): Promise> { + const result = await this.identityContext.withoutAuthorization(async () => { + return await this.getUniqueFieldValues.execute(this.fileModel, { + fieldId: "tags", + where: { + ...(input.where || {}), + latest: true + } + }); + }); + + if (result.isFail()) { + return Result.fail(new FilePersistenceError(result.error)); + } + + // Map to TagItem format + const tags: TagItem[] = result.value + .map(uv => ({ + tag: uv.value as string, + count: uv.count + })) + // Sort by tag name alphabetically + .sort((a, b) => (a.tag < b.tag ? -1 : 1)) + // Then sort by count descending (most used first) + .sort((a, b) => (a.count > b.count ? -1 : 1)); + + return Result.ok(tags); + } +} + +export const ListTagsRepository = RepositoryAbstraction.createImplementation({ + implementation: ListTagsRepositoryImpl, + dependencies: [GetUniqueFieldValuesUseCase, FileModel, IdentityContext] +}); diff --git a/packages/api-file-manager/src/features/file/ListTags/ListTagsUseCase.ts b/packages/api-file-manager/src/features/file/ListTags/ListTagsUseCase.ts new file mode 100644 index 00000000000..fa266a77009 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListTags/ListTagsUseCase.ts @@ -0,0 +1,42 @@ +import { Result } from "@webiny/feature/api"; +import { + ListTagsUseCase as UseCaseAbstraction, + ListTagsInput, + TagItem, + ListTagsRepository +} from "./abstractions.js"; +import { FileNotAuthorizedError } from "~/domain/file/errors.js"; +import { FilePermissions } from "~/features/shared/abstractions.js"; + +class ListTagsUseCaseImpl implements UseCaseAbstraction.Interface { + constructor( + private filePermissions: FilePermissions.Interface, + private repository: ListTagsRepository.Interface + ) {} + + async execute(input: ListTagsInput): Promise> { + // Check permission (ensure() with no args checks basic access) + const hasPermission = await this.filePermissions.ensure(); + if (!hasPermission) { + return Result.fail(new FileNotAuthorizedError()); + } + + const enrichedInput: ListTagsInput = { + ...input, + limit: input.limit || 1000000 + }; + + const result = await this.repository.execute(enrichedInput); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(result.value); + } +} + +export const ListTagsUseCase = UseCaseAbstraction.createImplementation({ + implementation: ListTagsUseCaseImpl, + dependencies: [FilePermissions, ListTagsRepository] +}); diff --git a/packages/api-file-manager/src/features/file/ListTags/abstractions.ts b/packages/api-file-manager/src/features/file/ListTags/abstractions.ts new file mode 100644 index 00000000000..d800c137a2f --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListTags/abstractions.ts @@ -0,0 +1,55 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; +import { type FilePersistenceError, FileNotAuthorizedError } from "~/domain/file/errors.js"; + +export interface ListTagsInput { + where?: Record; + after?: string | null; + limit?: number; +} + +export interface TagItem { + tag: string; + count: number; +} + +/** + * ListTags repository interface + */ +export interface IListTagsRepository { + execute(input: ListTagsInput): Promise>; +} + +export interface IListTagsRepositoryErrors { + persistence: FilePersistenceError; +} + +type RepositoryError = IListTagsRepositoryErrors[keyof IListTagsRepositoryErrors]; + +export const ListTagsRepository = createAbstraction("ListTagsRepository"); + +export namespace ListTagsRepository { + export type Interface = IListTagsRepository; + export type Error = RepositoryError; +} + +/** + * ListTags use case interface + */ +export interface IListTagsUseCase { + execute(input: ListTagsInput): Promise>; +} + +export interface IListTagsUseCaseErrors { + notAuthorized: FileNotAuthorizedError; + persistence: FilePersistenceError; +} + +type UseCaseError = IListTagsUseCaseErrors[keyof IListTagsUseCaseErrors]; + +export const ListTagsUseCase = createAbstraction("ListTagsUseCase"); + +export namespace ListTagsUseCase { + export type Interface = IListTagsUseCase; + export type Error = UseCaseError; +} diff --git a/packages/api-file-manager/src/features/file/ListTags/feature.ts b/packages/api-file-manager/src/features/file/ListTags/feature.ts new file mode 100644 index 00000000000..d97347ea081 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListTags/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { ListTagsRepository } from "./ListTagsRepository.js"; +import { ListTagsUseCase } from "./ListTagsUseCase.js"; + +export const ListTagsFeature = createFeature({ + name: "FileManager/ListTags", + register(container) { + container.register(ListTagsUseCase); + container.register(ListTagsRepository).inSingletonScope(); + } +}); diff --git a/packages/api-file-manager/src/features/file/ListTags/index.ts b/packages/api-file-manager/src/features/file/ListTags/index.ts new file mode 100644 index 00000000000..c1a809b1602 --- /dev/null +++ b/packages/api-file-manager/src/features/file/ListTags/index.ts @@ -0,0 +1 @@ +export { ListTagsUseCase } from "./abstractions.js"; From 9f622fa30d6607c1032082416ed7c316dedce426 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 23 Nov 2025 21:24:41 +0100 Subject: [PATCH 59/71] wip: migrate file manager --- .../src/domain/settings/types.ts | 1 - .../src/features/FileManagerFeature.ts | 20 +++- .../file/CreateFile/CreateFileUseCase.ts | 4 + .../CreateFilesInBatchUseCase.ts | 4 + .../file/GetFile/GetFileRepository.ts | 4 +- .../features/file/GetFile/GetFileUseCase.ts | 10 +- .../src/features/file/GetFile/abstractions.ts | 8 +- .../file/UpdateFile/UpdateFileUseCase.ts | 2 +- .../GetSettings/GetSettingsUseCase.ts | 26 ++--- .../settings/GetSettings/abstractions.ts | 10 +- .../features/settings/GetSettings/feature.ts | 2 +- .../settings/SettingsInstaller/feature.ts | 2 +- .../UpdateSettings/UpdateSettingsUseCase.ts | 41 ++++---- .../settings/UpdateSettings/feature.ts | 2 +- .../src/graphql/baseSchema.ts | 28 +++++- .../src/graphql/filesSchema.ts | 98 +++++++++++++------ packages/api-file-manager/src/index.ts | 14 +++ .../src/modelModifier/CmsModelModifier.ts | 2 +- 18 files changed, 187 insertions(+), 91 deletions(-) diff --git a/packages/api-file-manager/src/domain/settings/types.ts b/packages/api-file-manager/src/domain/settings/types.ts index abeb731d479..be0a9497b9f 100644 --- a/packages/api-file-manager/src/domain/settings/types.ts +++ b/packages/api-file-manager/src/domain/settings/types.ts @@ -1,5 +1,4 @@ export interface FileManagerSettings { - tenant: string; uploadMinFileSize: number; uploadMaxFileSize: number; srcPrefix: string; diff --git a/packages/api-file-manager/src/features/FileManagerFeature.ts b/packages/api-file-manager/src/features/FileManagerFeature.ts index 1666c16d2c8..a89fb2fad6c 100644 --- a/packages/api-file-manager/src/features/FileManagerFeature.ts +++ b/packages/api-file-manager/src/features/FileManagerFeature.ts @@ -1,9 +1,27 @@ import { createFeature } from "@webiny/feature/api"; -import { SettingsInstallerFeature } from "~/features/settings/feature.js"; +import { CreateFileFeature } from "~/features/file/CreateFile/feature.js"; +import { CreateFilesInBatchFeature } from "~/features/file/CreateFilesInBatch/feature.js"; +import { DeleteFileFeature } from "~/features/file/DeleteFile/feature.js"; +import { GetFileFeature } from "~/features/file/GetFile/feature.js"; +import { ListFilesFeature } from "~/features/file/ListFiles/feature.js"; +import { ListTagsFeature } from "~/features/file/ListTags/feature.js"; +import { UpdateFileFeature } from "~/features/file/UpdateFile/feature.js"; +import { SettingsInstallerFeature } from "~/features/settings/SettingsInstaller/feature.js"; +import { GetSettingsFeature } from "~/features/settings/GetSettings/feature.js"; +import { UpdateSettingsFeature } from "~/features/settings/UpdateSettings/feature.js"; export const FileManagerFeature = createFeature({ name: "FileManager", register(container) { + CreateFileFeature.register(container); + CreateFilesInBatchFeature.register(container); + UpdateFileFeature.register(container); + DeleteFileFeature.register(container); + GetFileFeature.register(container); + ListFilesFeature.register(container); + ListTagsFeature.register(container); SettingsInstallerFeature.register(container); + GetSettingsFeature.register(container); + UpdateSettingsFeature.register(container); } }); diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts index 05a478d178c..a16c7f50da7 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts @@ -67,6 +67,10 @@ class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { ): Promise> { const settingsResult = await this.getSettings.execute(); + if (settingsResult.isFail()) { + return Result.ok(); + } + const settings = settingsResult.value; if (settings) { diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts index 8a18a12cec9..d6e9bfe0058 100644 --- a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchUseCase.ts @@ -74,6 +74,10 @@ class CreateFilesInBatchUseCaseImpl implements UseCaseAbstraction.Interface { ): Promise> { const settingsResult = await this.getSettings.execute(); + if (settingsResult.isFail()) { + return Result.ok(); + } + const settings = settingsResult.value; if (settings) { diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts index 737855395d3..0ffc77880a8 100644 --- a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts @@ -14,9 +14,9 @@ class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { private identityContext: IdentityContext.Interface ) {} - async getById(id: string): Promise> { + async execute(id: string): Promise> { const result = await this.identityContext.withoutAuthorization(async () => { - return await this.getEntryById.execute(this.fileModel, id); + return await this.getEntryById.execute(this.fileModel, `${id}#0001`); }); if (result.isFail()) { diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts index 80edf32d725..dda4abc7d34 100644 --- a/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileUseCase.ts @@ -1,9 +1,5 @@ import { Result } from "@webiny/feature/api"; -import { - GetFileUseCase as UseCaseAbstraction, - GetFileInput, - GetFileRepository -} from "./abstractions.js"; +import { GetFileUseCase as UseCaseAbstraction, GetFileRepository } from "./abstractions.js"; import type { File } from "~/domain/file/types.js"; import { FileNotAuthorizedError } from "~/domain/file/errors.js"; import { FilePermissions } from "~/features/shared/abstractions.js"; @@ -14,14 +10,14 @@ class GetFileUseCaseImpl implements UseCaseAbstraction.Interface { private repository: GetFileRepository.Interface ) {} - async execute(input: GetFileInput): Promise> { + async execute(id: string): Promise> { // Check read permission const hasPermission = await this.filePermissions.ensure({ rwd: "r" }); if (!hasPermission) { return Result.fail(new FileNotAuthorizedError()); } - const result = await this.repository.getById(input.id); + const result = await this.repository.execute(id); if (result.isFail()) { return Result.fail(result.error); diff --git a/packages/api-file-manager/src/features/file/GetFile/abstractions.ts b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts index 7bd0f3ae5b0..93e7651a78d 100644 --- a/packages/api-file-manager/src/features/file/GetFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts @@ -7,15 +7,11 @@ import { FileNotAuthorizedError } from "~/domain/file/errors.js"; -export interface GetFileInput { - id: string; -} - /** * GetFile repository interface */ export interface IGetFileRepository { - getById(id: string): Promise>; + execute(id: string): Promise>; } export interface IGetFileRepositoryErrors { @@ -36,7 +32,7 @@ export namespace GetFileRepository { * GetFile use case interface */ export interface IGetFileUseCase { - execute(input: GetFileInput): Promise>; + execute(id: string): Promise>; } export interface IGetFileUseCaseErrors { diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts index 44699029df8..09c0c53c236 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts @@ -27,7 +27,7 @@ class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { } // Get original file (includes ownership check) - const getResult = await this.getFile.execute({ id: input.id }); + const getResult = await this.getFile.execute(input.id); if (getResult.isFail()) { return Result.fail(getResult.error); } diff --git a/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts index 2cdc928f329..92c46b38264 100644 --- a/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts @@ -1,25 +1,27 @@ import { Result } from "@webiny/feature/api"; -import { - GetSettingsUseCase as UseCaseAbstraction -} from "./abstractions.js"; +import { GetSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetSettings } from "@webiny/api-core/features/settings/GetSettings"; import type { FileManagerSettings } from "~/domain/settings/types.js"; - -const SETTINGS_NAME = "file-manager"; +import { SettingsNotFoundError } from "~/domain/settings/errors.js"; +import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; class GetSettingsUseCaseImpl implements UseCaseAbstraction.Interface { - constructor( - private getSettings: GetSettings.Interface - ) {} + constructor(private getSettings: GetSettings.Interface) {} - async execute(): Promise> { - const result = await this.getSettings.execute({ name: SETTINGS_NAME }); + async execute(): Promise> { + const result = await this.getSettings.execute(FILE_MANAGER_GENERAL_SETTINGS); if (result.isFail()) { - return Result.ok(null); + return Result.ok({ + uploadMinFileSize: 0, + uploadMaxFileSize: 10737418240, + srcPrefix: "" + }); } - return Result.ok(result.value); + const settings = result.value.data as FileManagerSettings; + + return Result.ok(settings); } } diff --git a/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts index 52eb0453c56..274a7e9f69e 100644 --- a/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts +++ b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts @@ -1,16 +1,24 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; import type { FileManagerSettings } from "~/domain/settings/types.js"; +import { SettingsNotFoundError } from "~/domain/settings/errors.js"; + +export interface IGetSettingsUseCaseErrors { + notFound: SettingsNotFoundError; +} + +type UseCaseError = IGetSettingsUseCaseErrors[keyof IGetSettingsUseCaseErrors]; /** * GetSettings use case - retrieves file manager settings. */ export interface IGetSettingsUseCase { - execute(): Promise>; + execute(): Promise>; } export const GetSettingsUseCase = createAbstraction("GetSettingsUseCase"); export namespace GetSettingsUseCase { export type Interface = IGetSettingsUseCase; + export type Error = UseCaseError; } diff --git a/packages/api-file-manager/src/features/settings/GetSettings/feature.ts b/packages/api-file-manager/src/features/settings/GetSettings/feature.ts index 441a757718c..95c45e963ae 100644 --- a/packages/api-file-manager/src/features/settings/GetSettings/feature.ts +++ b/packages/api-file-manager/src/features/settings/GetSettings/feature.ts @@ -2,7 +2,7 @@ import { createFeature } from "@webiny/feature/api"; import { GetSettingsUseCase } from "./GetSettingsUseCase.js"; export const GetSettingsFeature = createFeature({ - name: "FileManager.GetSettings", + name: "FileManager/GetSettings", register(container) { container.register(GetSettingsUseCase); } diff --git a/packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts b/packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts index e522820257e..294d6ebffae 100644 --- a/packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts +++ b/packages/api-file-manager/src/features/settings/SettingsInstaller/feature.ts @@ -1,5 +1,5 @@ import { createFeature } from "@webiny/feature/api"; -import { SettingsInstaller } from "~/features/settings/SettingsInstaller.js"; +import { SettingsInstaller } from "./SettingsInstaller.js"; export const SettingsInstallerFeature = createFeature({ name: "FileManager/SettingsInstaller", diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index 216d2a04247..5b33d08a66a 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -1,13 +1,10 @@ import { Result } from "@webiny/feature/api"; -import { - UpdateSettingsUseCase as UseCaseAbstraction -} from "./abstractions.js"; +import { UpdateSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { UpdateSettings } from "@webiny/api-core/features/settings/UpdateSettings"; import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; import { SettingsUpdateError } from "~/domain/settings/errors.js"; - -const SETTINGS_NAME = "file-manager"; +import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -15,28 +12,26 @@ class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { private getSettings: GetSettingsUseCase.Interface ) {} - async execute(input: UpdateSettingsInput): Promise> { - try { - // Get existing settings to merge with new data - const existingResult = await this.getSettings.execute(); - const existing = existingResult.value; - - const result = await this.updateSettings.execute({ - name: SETTINGS_NAME, - data: { - ...existing, - ...input - } - }); + async execute( + input: UpdateSettingsInput + ): Promise> { + // Get existing settings to merge with new data + const existingResult = await this.getSettings.execute(); + const existing = existingResult.value; - if (result.isFail()) { - return Result.fail(new SettingsUpdateError(result.error)); + const result = await this.updateSettings.execute({ + name: FILE_MANAGER_GENERAL_SETTINGS, + data: { + ...existing, + ...input } + }); - return Result.ok(result.value); - } catch (error) { - return Result.fail(new SettingsUpdateError(error as Error)); + if (result.isFail()) { + return Result.fail(new SettingsUpdateError(result.error)); } + + return Result.ok(result.value.data as FileManagerSettings); } } diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts index ed30d8d9d0d..a29331e7b6f 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts @@ -3,7 +3,7 @@ import { UpdateSettingsUseCase } from "./UpdateSettingsUseCase.js"; import { UpdateSettingsEventsDecorator } from "./UpdateSettingsEventsDecorator.js"; export const UpdateSettingsFeature = createFeature({ - name: "FileManager.UpdateSettings", + name: "FileManager/UpdateSettings", register(container) { container.register(UpdateSettingsUseCase); // Register the decorator which wraps the use case with events diff --git a/packages/api-file-manager/src/graphql/baseSchema.ts b/packages/api-file-manager/src/graphql/baseSchema.ts index e939ede9dda..59b203b0bec 100644 --- a/packages/api-file-manager/src/graphql/baseSchema.ts +++ b/packages/api-file-manager/src/graphql/baseSchema.ts @@ -1,6 +1,12 @@ -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { + ErrorResponse, + GraphQLSchemaPlugin, + Response +} from "@webiny/handler-graphql"; import type { FileManagerContext } from "~/types.js"; -import { emptyResolver, resolve } from "./utils.js"; +import { emptyResolver } from "./utils.js"; +import { GetSettingsUseCase } from "~/features/settings/GetSettings/abstractions.js"; +import { UpdateSettingsUseCase } from "~/features/settings/UpdateSettings/abstractions.js"; export const createBaseSchema = () => { const fileManagerGraphQL = new GraphQLSchemaPlugin({ @@ -76,12 +82,26 @@ export const createBaseSchema = () => { }, FmQuery: { async getSettings(_, __, context) { - return resolve(() => context.fileManager.getSettings()); + const getSettings = context.container.resolve(GetSettingsUseCase); + const result = await getSettings.execute(); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); } }, FmMutation: { async updateSettings(_, args: any, context) { - return resolve(() => context.fileManager.updateSettings(args.data)); + const updateSettings = context.container.resolve(UpdateSettingsUseCase); + const result = await updateSettings.execute(args.data); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); } } } diff --git a/packages/api-file-manager/src/graphql/filesSchema.ts b/packages/api-file-manager/src/graphql/filesSchema.ts index bff63c21c41..a7bb24fe739 100644 --- a/packages/api-file-manager/src/graphql/filesSchema.ts +++ b/packages/api-file-manager/src/graphql/filesSchema.ts @@ -9,6 +9,14 @@ import { emptyResolver, resolve } from "./utils.js"; import type { CreateFilesTypeDefsParams } from "~/graphql/createFilesTypeDefs.js"; import { createFilesTypeDefs } from "~/graphql/createFilesTypeDefs.js"; import NotAuthorizedResponse from "@webiny/api-core/graphql/security/NotAuthorizedResponse.js"; +import { GetFileUseCase } from "~/features/file/GetFile/abstractions.js"; +import { ListFilesUseCase } from "~/features/file/ListFiles/abstractions.js"; +import { ListTagsUseCase } from "~/features/file/ListTags/abstractions.js"; +import { CreateFileUseCase } from "~/features/file/CreateFile/abstractions.js"; +import { CreateFilesInBatchUseCase } from "~/features/file/CreateFilesInBatch/abstractions.js"; +import { UpdateFileUseCase } from "~/features/file/UpdateFile/abstractions.js"; +import { DeleteFileUseCase } from "~/features/file/DeleteFile/abstractions.js"; +import { GetSettingsUseCase } from "~/features/settings/GetSettings/abstractions.js"; export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { const fileManagerGraphQL = new GraphQLSchemaPlugin({ @@ -23,7 +31,9 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { FmFile: { async src(file, _, context) { // TODO: create `FileUrlGenerator` service to use here - const settings = await context.fileManager.getSettings(); + const getSettings = context.container.resolve(GetSettingsUseCase); + const result = await getSettings.execute(); + const settings = result.value; return (settings?.srcPrefix || "") + file.key; } }, @@ -36,54 +46,84 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { return resolve(() => context.cms.getModel("fmFile")); }, - getFile(_, args: any, context) { - return resolve(() => context.fileManager.getFile(args.id)); + async getFile(_, args: any, context) { + const getFile = context.container.resolve(GetFileUseCase); + const result = await getFile.execute(args.id); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); }, async listFiles(_, args: FilesListOpts, context) { - try { - const [data, meta] = await context.fileManager.listFiles(args); + const listFiles = context.container.resolve(ListFilesUseCase); + const result = await listFiles.execute(args); - return new ListResponse(data, meta); - } catch (e) { - return new ErrorResponse(e); + if (result.isFail()) { + return new ErrorResponse(result.error); } + + return new ListResponse(result.value.items, result.value.meta); }, async listTags(_, args: any, context) { - try { - const tags = await context.fileManager.listTags(args || {}); + const listTags = context.container.resolve(ListTagsUseCase); + const result = await listTags.execute(args || {}); - return new Response(tags); - } catch (error) { - return new ErrorResponse(error); + if (result.isFail()) { + return new ErrorResponse(result.error); } + + return new Response(result.value); } }, FmMutation: { async createFile(_, args: any, context) { - return resolve(() => { - return context.fileManager.createFile(args.data); - }); + const createFile = context.container.resolve(CreateFileUseCase); + const result = await createFile.execute(args.data); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); }, async createFiles(_, args: any, context) { - return resolve(() => { - return context.fileManager.createFilesInBatch(args.data, args.meta); + const createFilesInBatch = context.container.resolve(CreateFilesInBatchUseCase); + const result = await createFilesInBatch.execute({ + files: args.data, + meta: args.meta }); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); }, async updateFile(_, args: any, context) { - return resolve(() => { - return context.fileManager.updateFile(args.id, args.data); + const updateFile = context.container.resolve(UpdateFileUseCase); + const result = await updateFile.execute({ + id: args.id, + ...args.data }); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + return new Response(result.value); }, async deleteFile(_, args: any, context) { - return resolve(async () => { - // TODO: 1) implement via a DeleteFile use case - // TODO: 2) deletion from Cloud storage should be implemented in the `api-file-manager-s3` as an event handler - // const file = await context.fileManager.getFile(args.id); - // return await context.fileManager.storage.delete({ - // id: file.id, - // key: file.key - // }); - }); + const deleteFile = context.container.resolve(DeleteFileUseCase); + const result = await deleteFile.execute({ id: args.id }); + + if (result.isFail()) { + return new ErrorResponse(result.error); + } + + // TODO: deletion from Cloud storage should be implemented in the `api-file-manager-s3` as an event handler + return new Response(true); } } } diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index 502b091b27c..551c32c3e49 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -9,6 +9,10 @@ import { FileManagerFeature } from "~/features/FileManagerFeature.js"; import { FilesPermissions as FilePermissionsImpl } from "~/createFileManager/permissions/FilesPermissions.js"; import { SettingsPermissions as SettingsPermissionsImpl } from "~/createFileManager/permissions/SettingsPermissions.js"; import { FilePermissions, SettingsPermissions } from "~/features/shared/abstractions.js"; +import { createFileModel, FILE_MODEL_ID } from "~/domain/file/fileModel.js"; +import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { FileModel } from "~/domain/file/abstractions.js"; export * from "./modelModifier/CmsModelModifier.js"; export * from "./plugins/index.js"; @@ -23,6 +27,16 @@ export const createFileManagerContext = ({ // context.fileManager = applyThreatScanning(context.fileManager); // } + const getModel = context.container.resolve(GetModelUseCase); + const wcpContext = context.container.resolve(WcpContext); + const withPrivateFiles = wcpContext.canUsePrivateFiles(); + + const fileModelDefinition = createFileModel({ withPrivateFiles }); + context.plugins.register(fileModelDefinition); + + const fileModel = await getModel.execute(FILE_MODEL_ID); + context.container.registerInstance(FileModel, fileModel.value); + const identityContext = context.container.resolve(IdentityContext); const filePermissions = new FilePermissionsImpl({ diff --git a/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts b/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts index 6d71f4e76d5..45585ee8c5e 100644 --- a/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts +++ b/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts @@ -2,7 +2,7 @@ import { Plugin } from "@webiny/plugins"; import type { CmsModelField as BaseModelField } from "@webiny/api-headless-cms/types/index.js"; import type { CmsPrivateModelFull } from "@webiny/api-headless-cms"; import { createModelField } from "@webiny/api-headless-cms"; -import { FILE_MODEL_ID } from "~/cmsFileStorage/file.model.js"; +import { FILE_MODEL_ID } from "~/domain/file/fileModel"; type CmsModelField = Omit & { bulkEdit?: boolean }; From 03b50c2fd479a24d74d186650014c67ede2e2243 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 30 Nov 2025 20:11:39 +0100 Subject: [PATCH 60/71] wip: migrate file manager --- .../features/security/utils/AppPermissions.ts | 6 +- .../src/AliasesStorageOperations.ts | 23 +- .../processThreatScanResult.ts | 31 +- .../CreateFileWithThreatScanDecorator.ts | 23 + .../enterprise/ApplyThreatScanning/feature.ts | 9 + .../DeleteFileFromBucketHandler.ts | 27 + .../features/DeleteFileFromBucket/feature.ts | 9 + .../features/FlushCache/CdnPathsGenerator.ts | 7 + .../FlushCacheOnFileDeleteHandler.ts | 28 + .../FlushCacheOnFileUpdateHandler.ts | 36 ++ .../src/features/FlushCache/feature.ts | 11 + .../WriteFileMetadata/MetadataWriter.ts} | 25 +- .../WriteMetadataAfterBatchCreateHandler.ts | 24 + .../WriteMetadataAfterCreateHandler.ts | 23 + .../src/features/WriteFileMetadata/feature.ts | 11 + .../flushCdnCache/flushCacheOnFileDelete.ts | 33 -- .../flushCdnCache/flushCacheOnFileUpdate.ts | 40 -- .../src/flushCdnCache/index.ts | 4 +- .../{plugins => graphql}/checkPermissions.ts | 7 +- .../schema.ts} | 55 +- packages/api-file-manager-s3/src/index.ts | 26 +- .../src/plugins/fileStorageS3.ts | 55 -- .../api-file-manager-s3/tsconfig.build.json | 4 +- packages/api-file-manager-s3/tsconfig.json | 4 +- .../__tests__/file.extensions.test.ts | 3 +- .../__tests__/file.lifecycle.test.ts | 36 +- .../__tests__/fileModelModifier.test.ts | 3 +- .../__tests__/fileSchema.test.ts | 2 +- .../__tests__/filesSecurity.test.ts | 64 +-- .../__tests__/filesSettings.lifecycle.test.ts | 67 --- .../__tests__/filesSettings.test.ts | 6 +- .../mocks/fileWithoutExtensions.sdl.ts | 480 ++++++++++++++++++ .../__tests__/mocks/lifecycleEvents.ts | 80 ++- .../__tests__/utils/plugins.ts | 18 +- .../src/FileManagerContextSetup.ts | 115 ----- .../src/cmsFileStorage/CmsFilesStorage.ts | 168 ------ .../cmsFileStorage/ListFilesWhereProcessor.ts | 32 -- .../cmsFileStorage/ListTagsWhereProcessor.ts | 32 -- .../src/createFileManager/files.crud.ts | 2 +- .../src/createFileManager/index.ts | 15 - .../src/createFileManager/settings.crud.ts | 134 ----- .../src/createFileManager/types.ts | 16 - .../AssetDelivery/AssetDeliveryConfig.ts | 14 +- .../AssetDelivery/SetResponseHeaders.ts | 4 +- .../privateFiles/AssetAuthorizer.ts | 2 +- .../PrivateAuthenticatedAuthorizer.ts | 7 +- .../PrivateFilesAssetProcessor.ts | 19 +- .../src/delivery/setupAssetDelivery.ts | 4 +- .../api-file-manager/src/domain/file/types.ts | 12 +- .../src/domain/identity/Identity.ts | 15 + .../src/domain/settings/errors.ts | 16 + .../src/domain/settings/validation.ts | 32 ++ .../src/enterprise/applyThreatScanning.ts | 17 - .../file/CreateFile/CreateFileUseCase.ts | 23 +- .../features/file/CreateFile/abstractions.ts | 9 +- .../file/DeleteFile/DeleteFileUseCase.ts | 4 +- .../features/file/DeleteFile/abstractions.ts | 2 +- .../file/UpdateFile/UpdateFileUseCase.ts | 39 +- .../features/file/UpdateFile/abstractions.ts | 8 +- .../features/file/shared/EntryToFileMapper.ts | 4 +- .../GetSettings/GetSettingsUseCase.ts | 2 +- .../settings/GetSettings/abstractions.ts | 5 +- .../UpdateSettingsEventsDecorator.ts | 97 ---- .../UpdateSettings/UpdateSettingsUseCase.ts | 55 +- .../settings/UpdateSettings/abstractions.ts | 5 +- .../settings/UpdateSettings/events.ts | 57 +++ .../settings/UpdateSettings/feature.ts | 3 - .../src/graphql/baseSchema.ts | 10 +- .../src/graphql/filesSchema.ts | 13 +- .../src/graphql/getFileByUrl.ts | 18 +- .../api-file-manager/src/graphql/index.ts | 20 +- packages/api-file-manager/src/index.ts | 26 +- .../src/modelModifier/CmsModelModifier.ts | 2 +- .../permissions/FilesPermissions.ts | 0 .../permissions/SettingsPermissions.ts | 0 .../src/plugins/FilePhysicalStoragePlugin.ts | 53 -- .../src/plugins/FileStorageTransformPlugin.ts | 60 --- .../api-file-manager/src/plugins/index.ts | 2 - .../src/storage/FileStorage.ts | 33 -- packages/api-file-manager/src/types.ts | 149 +----- .../src/types/file.lifecycle.ts | 53 -- packages/api-file-manager/src/types/file.ts | 53 -- .../api-headless-cms/src/types/identity.ts | 2 +- .../KickOutCurrentUserUseCase.ts | 10 +- .../api-record-locking/tsconfig.build.json | 4 +- packages/api-record-locking/tsconfig.json | 4 +- packages/api-websockets/package.json | 2 +- packages/api-websockets/src/context/index.ts | 4 +- .../abstractions.ts | 4 +- .../src/features/WebsocketService/index.ts | 1 + .../src/features/WebsocketsContext/index.ts | 1 - .../appTemplates/api/graphql/src/index.ts | 4 +- .../OpenSearch/api/graphql/src/index.ts | 4 +- packages/project-aws/tsconfig.build.json | 4 +- packages/project-aws/tsconfig.json | 4 +- packages/tasks/package.json | 1 + packages/tasks/src/context.ts | 10 +- .../src/features/TaskService/abstractions.ts | 8 + packages/tasks/tsconfig.build.json | 5 + packages/tasks/tsconfig.json | 5 + packages/testing/tsconfig.build.json | 4 +- packages/testing/tsconfig.json | 4 +- yarn.lock | 1 + 103 files changed, 1276 insertions(+), 1551 deletions(-) create mode 100644 packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/CreateFileWithThreatScanDecorator.ts create mode 100644 packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts create mode 100644 packages/api-file-manager-s3/src/features/DeleteFileFromBucket/DeleteFileFromBucketHandler.ts create mode 100644 packages/api-file-manager-s3/src/features/DeleteFileFromBucket/feature.ts create mode 100644 packages/api-file-manager-s3/src/features/FlushCache/CdnPathsGenerator.ts create mode 100644 packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileDeleteHandler.ts create mode 100644 packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileUpdateHandler.ts create mode 100644 packages/api-file-manager-s3/src/features/FlushCache/feature.ts rename packages/api-file-manager-s3/src/{plugins/addFileMetadata.ts => features/WriteFileMetadata/MetadataWriter.ts} (62%) create mode 100644 packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterBatchCreateHandler.ts create mode 100644 packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterCreateHandler.ts create mode 100644 packages/api-file-manager-s3/src/features/WriteFileMetadata/feature.ts delete mode 100644 packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts delete mode 100644 packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts rename packages/api-file-manager-s3/src/{plugins => graphql}/checkPermissions.ts (81%) rename packages/api-file-manager-s3/src/{plugins/graphqlFileStorageS3.ts => graphql/schema.ts} (82%) delete mode 100644 packages/api-file-manager-s3/src/plugins/fileStorageS3.ts delete mode 100644 packages/api-file-manager/__tests__/filesSettings.lifecycle.test.ts create mode 100644 packages/api-file-manager/__tests__/mocks/fileWithoutExtensions.sdl.ts delete mode 100644 packages/api-file-manager/src/FileManagerContextSetup.ts delete mode 100644 packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts delete mode 100644 packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts delete mode 100644 packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts delete mode 100644 packages/api-file-manager/src/createFileManager/index.ts delete mode 100644 packages/api-file-manager/src/createFileManager/settings.crud.ts delete mode 100644 packages/api-file-manager/src/createFileManager/types.ts create mode 100644 packages/api-file-manager/src/domain/identity/Identity.ts create mode 100644 packages/api-file-manager/src/domain/settings/validation.ts delete mode 100644 packages/api-file-manager/src/enterprise/applyThreatScanning.ts delete mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts create mode 100644 packages/api-file-manager/src/features/settings/UpdateSettings/events.ts rename packages/api-file-manager/src/{createFileManager => }/permissions/FilesPermissions.ts (100%) rename packages/api-file-manager/src/{createFileManager => }/permissions/SettingsPermissions.ts (100%) delete mode 100644 packages/api-file-manager/src/plugins/FilePhysicalStoragePlugin.ts delete mode 100644 packages/api-file-manager/src/plugins/FileStorageTransformPlugin.ts delete mode 100644 packages/api-file-manager/src/plugins/index.ts delete mode 100644 packages/api-file-manager/src/storage/FileStorage.ts delete mode 100644 packages/api-file-manager/src/types/file.lifecycle.ts delete mode 100644 packages/api-file-manager/src/types/file.ts rename packages/api-websockets/src/features/{WebsocketsContext => WebsocketService}/abstractions.ts (60%) create mode 100644 packages/api-websockets/src/features/WebsocketService/index.ts delete mode 100644 packages/api-websockets/src/features/WebsocketsContext/index.ts create mode 100644 packages/tasks/src/features/TaskService/abstractions.ts diff --git a/packages/api-core/src/features/security/utils/AppPermissions.ts b/packages/api-core/src/features/security/utils/AppPermissions.ts index a61562ec69c..87d10a0170c 100644 --- a/packages/api-core/src/features/security/utils/AppPermissions.ts +++ b/packages/api-core/src/features/security/utils/AppPermissions.ts @@ -66,11 +66,7 @@ export class AppPermissions; private readonly table: Table; @@ -27,11 +28,11 @@ export class AliasesStorageOperations implements FileAliasesStorageOperations { this.aliasEntity = createStandardEntity({ table: this.table, - name: "FM.FileAlias" + name: "FM.FileAliasStorageDto" }); } - async deleteAliases(file: File): Promise { + async deleteAliases(file: FileStorageDto): Promise { const aliasItems = await this.getExistingAliases(file); const batchWrite = createEntityWriteBatch({ @@ -50,7 +51,7 @@ export class AliasesStorageOperations implements FileAliasesStorageOperations { await batchWrite.execute(); } - async storeAliases(file: File): Promise { + async storeAliases(file: FileStorageDto): Promise { const existingAliases = await this.getExistingAliases(file); const newAliases = this.createNewAliasesRecords(file, existingAliases); @@ -74,8 +75,8 @@ export class AliasesStorageOperations implements FileAliasesStorageOperations { await batchWrite.execute(); } - private async getExistingAliases(file: File): Promise { - const aliases = await queryAll<{ data: FileAlias }>({ + private async getExistingAliases(file: FileStorageDto): Promise { + const aliases = await queryAll<{ data: FileAliasStorageDto }>({ entity: this.aliasEntity, partitionKey: this.createPartitionKey(file), options: { @@ -92,9 +93,9 @@ export class AliasesStorageOperations implements FileAliasesStorageOperations { } private createNewAliasesRecords( - file: File, - existingAliases: FileAlias[] = [] - ): DbItem[] { + file: FileStorageDto, + existingAliases: FileAliasStorageDto[] = [] + ): DbItem[] { return (file.aliases || []) .map(alias => { // If alias is already in the DB, skip it. @@ -117,6 +118,6 @@ export class AliasesStorageOperations implements FileAliasesStorageOperations { } }; }) - .filter(Boolean) as DbItem[]; + .filter(Boolean) as DbItem[]; } } diff --git a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts index cef984778ba..6b86cad7162 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts @@ -1,35 +1,46 @@ import type { GuardDutyEvent, ThreatDetectionContext } from "./types.js"; +import { ListFilesUseCase } from "@webiny/api-file-manager/features/file/ListFiles/index.js"; +import { UpdateFileUseCase } from "@webiny/api-file-manager/features/file/UpdateFile/index.js"; +import { DeleteFileUseCase } from "@webiny/api-file-manager/features/file/DeleteFile/index.js"; +import { WebsocketService } from "@webiny/api-websockets/features/WebsocketService/index.js"; export const processThreatScanResult = async ( context: ThreatDetectionContext, eventDetail: GuardDutyEvent ) => { + const websocketService = context.container.resolve(WebsocketService); + const listFiles = context.container.resolve(ListFilesUseCase); + const updateFile = context.container.resolve(UpdateFileUseCase); + const deleteFile = context.container.resolve(DeleteFileUseCase); + await context.security.withoutAuthorization(async () => { try { const scanStatus = eventDetail.scanResultDetails.scanResultStatus; const s3Object = eventDetail.s3ObjectDetails; - const [[file]] = await context.fileManager.listFiles({ + const listResult = await listFiles.execute({ limit: 1, where: { key: s3Object.objectKey } }); + const [file] = listResult.value.items; + if (!file) { return; } - const allConnections = await context.websockets.listConnections(); + const allConnections = await websocketService.listConnections(); if (scanStatus === "NO_THREATS_FOUND") { const newTags = file.tags.filter(tag => tag !== "threatScanInProgress"); - await context.fileManager.updateFile(file.id, { - tags: newTags, - savedBy: file.savedBy + await updateFile.execute({ + id: file.id, + tags: newTags }); - await context.websockets.sendToConnections(allConnections, { + await websocketService.sendToConnections(allConnections, { action: "fm.threatScan.noThreatFound", data: { id: file.id, @@ -42,9 +53,9 @@ export const processThreatScanResult = async ( if (scanStatus === "THREATS_FOUND") { // Delete infected file. - await context.fileManager.deleteFile(file.id); + await deleteFile.execute(file.id); - await context.websockets.sendToConnections(allConnections, { + await websocketService.sendToConnections(allConnections, { action: "fm.threatScan.threatDetected", data: { id: file.id, @@ -56,9 +67,9 @@ export const processThreatScanResult = async ( } // For all other outcomes, we delete the file, until better logic is implemented. - await context.fileManager.deleteFile(file.id); + await deleteFile.execute(file.id); - await context.websockets.sendToConnections(allConnections, { + await websocketService.sendToConnections(allConnections, { action: "fm.threatScan.unsupported", data: { id: file.id, diff --git a/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/CreateFileWithThreatScanDecorator.ts b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/CreateFileWithThreatScanDecorator.ts new file mode 100644 index 00000000000..742aa1bb65a --- /dev/null +++ b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/CreateFileWithThreatScanDecorator.ts @@ -0,0 +1,23 @@ +import { CreateFileUseCase } from "@webiny/api-file-manager/features/file/CreateFile/abstractions.js"; +import type { CreateFileInput } from "@webiny/api-file-manager/features/file/CreateFile/abstractions.js"; + +class CreateFileWithThreatScanDecoratorImpl implements CreateFileUseCase.Interface { + constructor(private decoratee: CreateFileUseCase.Interface) {} + + async execute( + input: CreateFileInput, + meta?: Record + ): ReturnType { + const modifiedInput: CreateFileInput = { + ...input, + tags: [...(input.tags || []), "threatScanInProgress"] + }; + + return this.decoratee.execute(modifiedInput, meta); + } +} + +export const CreateFileWithThreatScanDecorator = CreateFileUseCase.createDecorator({ + decorator: CreateFileWithThreatScanDecoratorImpl, + dependencies: [] +}); diff --git a/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts new file mode 100644 index 00000000000..e99f9459948 --- /dev/null +++ b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateFileWithThreatScanDecorator } from "./CreateFileWithThreatScanDecorator.js"; + +export const ApplyThreatScanningFeature = createFeature({ + name: "FileManagerS3/ApplyThreatScanning", + register(container) { + container.register(CreateFileWithThreatScanDecorator); + } +}); diff --git a/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/DeleteFileFromBucketHandler.ts b/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/DeleteFileFromBucketHandler.ts new file mode 100644 index 00000000000..4e3257317cc --- /dev/null +++ b/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/DeleteFileFromBucketHandler.ts @@ -0,0 +1,27 @@ +import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; +import { FileAfterDeleteHandler } from "@webiny/api-file-manager/features/file/DeleteFile/events.js"; + +const S3_BUCKET = process.env.S3_BUCKET; + +class DeleteFileFromBucketHandlerImpl implements FileAfterDeleteHandler.Interface { + async handle(event: FileAfterDeleteHandler.Event): Promise { + const { file } = event.payload; + const { key } = file; + + if (!key || !S3_BUCKET) { + return; + } + + const s3 = new S3(); + + await s3.deleteObject({ + Bucket: S3_BUCKET, + Key: key + }); + } +} + +export const DeleteFileFromBucketHandler = FileAfterDeleteHandler.createImplementation({ + implementation: DeleteFileFromBucketHandlerImpl, + dependencies: [] +}); diff --git a/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/feature.ts b/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/feature.ts new file mode 100644 index 00000000000..94feb0865ce --- /dev/null +++ b/packages/api-file-manager-s3/src/features/DeleteFileFromBucket/feature.ts @@ -0,0 +1,9 @@ +import { createFeature } from "@webiny/feature/api"; +import { DeleteFileFromBucketHandler } from "./DeleteFileFromBucketHandler.js"; + +export const DeleteFileFromBucketFeature = createFeature({ + name: "FileManagerS3/DeleteFileFromBucket", + register(container) { + container.register(DeleteFileFromBucketHandler); + } +}); diff --git a/packages/api-file-manager-s3/src/features/FlushCache/CdnPathsGenerator.ts b/packages/api-file-manager-s3/src/features/FlushCache/CdnPathsGenerator.ts new file mode 100644 index 00000000000..c7f5a505e68 --- /dev/null +++ b/packages/api-file-manager-s3/src/features/FlushCache/CdnPathsGenerator.ts @@ -0,0 +1,7 @@ +import type { File } from "@webiny/api-file-manager/domain/file/types.js"; + +export class CdnPathsGenerator { + generate(file: File) { + return [`/files/${file.key}*`, `/private/${file.key}*`, ...file.aliases]; + } +} diff --git a/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileDeleteHandler.ts b/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileDeleteHandler.ts new file mode 100644 index 00000000000..fb3407395ee --- /dev/null +++ b/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileDeleteHandler.ts @@ -0,0 +1,28 @@ +import { FileAfterDeleteHandler } from "@webiny/api-file-manager/features/file/DeleteFile/events.js"; +import { TaskService } from "@webiny/tasks/features/TaskService/abstractions.js"; +import { CdnPathsGenerator } from "./CdnPathsGenerator.js"; + +class FlushCacheOnFileDeleteHandlerImpl implements FileAfterDeleteHandler.Interface { + private readonly pathsGenerator: CdnPathsGenerator; + + constructor(private taskService: TaskService.Interface) { + this.pathsGenerator = new CdnPathsGenerator(); + } + + async handle(event: FileAfterDeleteHandler.Event): Promise { + const { file } = event.payload; + + await this.taskService.trigger({ + definition: "cloudfrontInvalidateCache", + input: { + caller: "fm-before-delete", + paths: this.pathsGenerator.generate(file) + } + }); + } +} + +export const FlushCacheOnFileDeleteHandler = FileAfterDeleteHandler.createImplementation({ + implementation: FlushCacheOnFileDeleteHandlerImpl, + dependencies: [TaskService] +}); diff --git a/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileUpdateHandler.ts b/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileUpdateHandler.ts new file mode 100644 index 00000000000..17348c00f70 --- /dev/null +++ b/packages/api-file-manager-s3/src/features/FlushCache/FlushCacheOnFileUpdateHandler.ts @@ -0,0 +1,36 @@ +import { FileBeforeUpdateHandler } from "@webiny/api-file-manager/features/file/UpdateFile/events.js"; +import { TaskService } from "@webiny/tasks/features/TaskService/abstractions.js"; +import { CdnPathsGenerator } from "./CdnPathsGenerator.js"; + +class FlushCacheOnFileUpdateHandlerImpl implements FileBeforeUpdateHandler.Interface { + private readonly pathsGenerator: CdnPathsGenerator; + + constructor(private taskService: TaskService.Interface) { + this.pathsGenerator = new CdnPathsGenerator(); + } + + async handle(event: FileBeforeUpdateHandler.Event): Promise { + const { file, original } = event.payload; + + const prevAccessControl = original.accessControl; + const newAccessControl = file.accessControl; + + // Only trigger cache flush if access control type has changed + if (prevAccessControl?.type === newAccessControl?.type) { + return; + } + + await this.taskService.trigger({ + definition: "cloudfrontInvalidateCache", + input: { + caller: "fm-before-update", + paths: this.pathsGenerator.generate(file) + } + }); + } +} + +export const FlushCacheOnFileUpdateHandler = FileBeforeUpdateHandler.createImplementation({ + implementation: FlushCacheOnFileUpdateHandlerImpl, + dependencies: [TaskService] +}); diff --git a/packages/api-file-manager-s3/src/features/FlushCache/feature.ts b/packages/api-file-manager-s3/src/features/FlushCache/feature.ts new file mode 100644 index 00000000000..18c4a63681b --- /dev/null +++ b/packages/api-file-manager-s3/src/features/FlushCache/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { FlushCacheOnFileDeleteHandler } from "./FlushCacheOnFileDeleteHandler.js"; +import { FlushCacheOnFileUpdateHandler } from "./FlushCacheOnFileUpdateHandler.js"; + +export const FlushCacheFeature = createFeature({ + name: "FileManagerS3/FlushCache", + register(container) { + container.register(FlushCacheOnFileDeleteHandler); + container.register(FlushCacheOnFileUpdateHandler); + } +}); diff --git a/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts b/packages/api-file-manager-s3/src/features/WriteFileMetadata/MetadataWriter.ts similarity index 62% rename from packages/api-file-manager-s3/src/plugins/addFileMetadata.ts rename to packages/api-file-manager-s3/src/features/WriteFileMetadata/MetadataWriter.ts index 2a775d12780..556106b952e 100644 --- a/packages/api-file-manager-s3/src/plugins/addFileMetadata.ts +++ b/packages/api-file-manager-s3/src/features/WriteFileMetadata/MetadataWriter.ts @@ -1,12 +1,14 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; -import { ContextPlugin } from "@webiny/api"; -import type { FileManagerContext, File } from "@webiny/api-file-manager/types.js"; +import type { File } from "@webiny/api-file-manager/domain/file/types.js"; import { executeWithRetry } from "@webiny/utils"; export class MetadataWriter { private readonly bucket: string; + private tenantContext: TenantContext.Interface; - constructor(bucket: string) { + constructor(tenantContext: TenantContext.Interface, bucket: string) { + this.tenantContext = tenantContext; this.bucket = bucket; } @@ -38,25 +40,12 @@ export class MetadataWriter { } private getMetadata(file: File) { + const tenant = this.tenantContext.getTenant(); return { id: file.id, - tenant: file.tenant, + tenant: tenant.id, size: file.size, contentType: file.type }; } } - -export const addFileMetadata = () => { - return new ContextPlugin(context => { - const metadataWriter = new MetadataWriter(String(process.env.S3_BUCKET)); - - context.fileManager.onFileAfterCreate.subscribe(({ file }) => { - return metadataWriter.write([file]); - }); - - context.fileManager.onFileAfterBatchCreate.subscribe(({ files }) => { - return metadataWriter.write(files); - }); - }); -}; diff --git a/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterBatchCreateHandler.ts b/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterBatchCreateHandler.ts new file mode 100644 index 00000000000..795eced3cfa --- /dev/null +++ b/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterBatchCreateHandler.ts @@ -0,0 +1,24 @@ +import { TenantContext } from "@webiny/api-core/features/TenantContext"; +import { FileAfterBatchCreateHandler } from "@webiny/api-file-manager/features/file/CreateFilesInBatch/events.js"; +import { MetadataWriter } from "./MetadataWriter.js"; + +const S3_BUCKET = process.env.S3_BUCKET; + +class WriteMetadataAfterBatchCreateHandlerImpl implements FileAfterBatchCreateHandler.Interface { + private readonly metadataWriter: MetadataWriter; + + constructor(tenantContext: TenantContext.Interface) { + this.metadataWriter = new MetadataWriter(tenantContext, String(S3_BUCKET)); + } + + async handle(event: FileAfterBatchCreateHandler.Event): Promise { + const { files } = event.payload; + await this.metadataWriter.write(files); + } +} + +export const WriteMetadataAfterBatchCreateHandler = + FileAfterBatchCreateHandler.createImplementation({ + implementation: WriteMetadataAfterBatchCreateHandlerImpl, + dependencies: [TenantContext] + }); diff --git a/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterCreateHandler.ts b/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterCreateHandler.ts new file mode 100644 index 00000000000..a1345477665 --- /dev/null +++ b/packages/api-file-manager-s3/src/features/WriteFileMetadata/WriteMetadataAfterCreateHandler.ts @@ -0,0 +1,23 @@ +import { FileAfterCreateHandler } from "@webiny/api-file-manager/features/file/CreateFile/events.js"; +import { MetadataWriter } from "./MetadataWriter.js"; +import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; + +const S3_BUCKET = process.env.S3_BUCKET; + +class WriteMetadataAfterCreateHandlerImpl implements FileAfterCreateHandler.Interface { + private readonly metadataWriter: MetadataWriter; + + constructor(tenantContext: TenantContext.Interface) { + this.metadataWriter = new MetadataWriter(tenantContext, String(S3_BUCKET)); + } + + async handle(event: FileAfterCreateHandler.Event): Promise { + const { file } = event.payload; + await this.metadataWriter.write([file]); + } +} + +export const WriteMetadataAfterCreateHandler = FileAfterCreateHandler.createImplementation({ + implementation: WriteMetadataAfterCreateHandlerImpl, + dependencies: [TenantContext] +}); diff --git a/packages/api-file-manager-s3/src/features/WriteFileMetadata/feature.ts b/packages/api-file-manager-s3/src/features/WriteFileMetadata/feature.ts new file mode 100644 index 00000000000..24362868e04 --- /dev/null +++ b/packages/api-file-manager-s3/src/features/WriteFileMetadata/feature.ts @@ -0,0 +1,11 @@ +import { createFeature } from "@webiny/feature/api"; +import { WriteMetadataAfterCreateHandler } from "./WriteMetadataAfterCreateHandler.js"; +import { WriteMetadataAfterBatchCreateHandler } from "./WriteMetadataAfterBatchCreateHandler.js"; + +export const WriteFileMetadataFeature = createFeature({ + name: "FileManagerS3/WriteFileMetadata", + register(container) { + container.register(WriteMetadataAfterCreateHandler); + container.register(WriteMetadataAfterBatchCreateHandler); + } +}); diff --git a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts deleted file mode 100644 index 23b739bbfe6..00000000000 --- a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileDelete.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ContextPlugin } from "@webiny/api"; -import type { - FileManagerContext, - OnFileBeforeUpdateTopicParams -} from "@webiny/api-file-manager/types.js"; -import { CdnPathsGenerator } from "~/flushCdnCache/CdnPathsGenerator.js"; - -class FlushCacheOnFileDelete { - private readonly context: FileManagerContext; - private readonly pathsGenerator: CdnPathsGenerator; - - constructor(context: FileManagerContext) { - this.pathsGenerator = new CdnPathsGenerator(); - this.context = context; - context.fileManager.onFileAfterDelete.subscribe(this.onFileAfterDelete); - } - - private onFileAfterDelete = async ({ file }: OnFileBeforeUpdateTopicParams) => { - await this.context.tasks.trigger({ - definition: "cloudfrontInvalidateCache", - input: { - caller: "fm-before-delete", - paths: this.pathsGenerator.generate(file) - } - }); - }; -} - -export const flushCacheOnFileDelete = () => { - return new ContextPlugin(context => { - new FlushCacheOnFileDelete(context); - }); -}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts b/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts deleted file mode 100644 index 3a8129d2456..00000000000 --- a/packages/api-file-manager-s3/src/flushCdnCache/flushCacheOnFileUpdate.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ContextPlugin } from "@webiny/api"; -import type { - FileManagerContext, - OnFileBeforeUpdateTopicParams -} from "@webiny/api-file-manager/types.js"; -import { CdnPathsGenerator } from "~/flushCdnCache/CdnPathsGenerator.js"; - -class FlushCacheOnFileUpdate { - private readonly context: FileManagerContext; - private readonly pathsGenerator: CdnPathsGenerator; - - constructor(context: FileManagerContext) { - this.pathsGenerator = new CdnPathsGenerator(); - this.context = context; - context.fileManager.onFileBeforeUpdate.subscribe(this.onFileBeforeUpdate); - } - - private onFileBeforeUpdate = async ({ file, original }: OnFileBeforeUpdateTopicParams) => { - const prevAccessControl = original.accessControl; - const newAccessControl = file.accessControl; - - if (prevAccessControl?.type === newAccessControl?.type) { - return; - } - - await this.context.tasks.trigger({ - definition: "cloudfrontInvalidateCache", - input: { - caller: "fm-before-update", - paths: this.pathsGenerator.generate(file) - } - }); - }; -} - -export const flushCacheOnFileUpdate = () => { - return new ContextPlugin(context => { - new FlushCacheOnFileUpdate(context); - }); -}; diff --git a/packages/api-file-manager-s3/src/flushCdnCache/index.ts b/packages/api-file-manager-s3/src/flushCdnCache/index.ts index e32b67143fc..9297f14e868 100644 --- a/packages/api-file-manager-s3/src/flushCdnCache/index.ts +++ b/packages/api-file-manager-s3/src/flushCdnCache/index.ts @@ -1,7 +1,5 @@ -import { flushCacheOnFileUpdate } from "~/flushCdnCache/flushCacheOnFileUpdate.js"; -import { flushCacheOnFileDelete } from "~/flushCdnCache/flushCacheOnFileDelete.js"; import { createInvalidateCacheTask } from "./invalidateCacheTaskDefinition.js"; export const flushCdnCache = () => { - return [flushCacheOnFileUpdate(), flushCacheOnFileDelete(), createInvalidateCacheTask()]; + return [createInvalidateCacheTask()]; }; diff --git a/packages/api-file-manager-s3/src/plugins/checkPermissions.ts b/packages/api-file-manager-s3/src/graphql/checkPermissions.ts similarity index 81% rename from packages/api-file-manager-s3/src/plugins/checkPermissions.ts rename to packages/api-file-manager-s3/src/graphql/checkPermissions.ts index d8e7e2b55e7..bbda9eb3093 100644 --- a/packages/api-file-manager-s3/src/plugins/checkPermissions.ts +++ b/packages/api-file-manager-s3/src/graphql/checkPermissions.ts @@ -1,11 +1,12 @@ -import type { FileManagerContext, FilePermission } from "@webiny/api-file-manager/types.js"; +import type { FilePermission } from "@webiny/api-file-manager/types.js"; import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; export const checkPermissions = async ( - context: FileManagerContext, + identityContext: IdentityContext.Interface, check: { rwd?: string } = {} ) => { - const filePermissions = await context.security.getPermissions("fm.file"); + const filePermissions = await identityContext.getPermissions("fm.file"); const relevantFilePermissions = filePermissions.filter(current => { if (check.rwd && !hasRwd(current, check.rwd)) { diff --git a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts b/packages/api-file-manager-s3/src/graphql/schema.ts similarity index 82% rename from packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts rename to packages/api-file-manager-s3/src/graphql/schema.ts index fa248baee77..8adf2b83d25 100644 --- a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts +++ b/packages/api-file-manager-s3/src/graphql/schema.ts @@ -1,20 +1,18 @@ -import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; import pMap from "p-map"; -import type { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types.js"; +import { createGraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; import { ErrorResponse, Response } from "@webiny/handler-graphql/responses.js"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import { getPresignedPostPayload } from "~/utils/getPresignedPostPayload.js"; -import WebinyError from "@webiny/error"; -import { checkPermissions } from "~/plugins/checkPermissions.js"; +import { checkPermissions } from "./checkPermissions.js"; import type { PresignedPostPayloadData } from "~/types.js"; import { CreateMultiPartUploadUseCase } from "~/multiPartUpload/CreateMultiPartUploadUseCase.js"; import { CompleteMultiPartUploadUseCase } from "~/multiPartUpload/CompleteMultiPartUploadUseCase.js"; import { createFileNormalizerFromContext } from "~/utils/createFileNormalizerFromContext.js"; +import { GetSettingsUseCase } from "@webiny/api-file-manager/features/settings/GetSettings/abstractions.js"; -const plugin: GraphQLSchemaPlugin = { - type: "graphql-schema", - name: "graphql-schema-api-file-manager-s3", - schema: { +export const createS3GraphQLSchema = () => { + return createGraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type UploadFileResponseDataFile { id: ID! @@ -107,19 +105,16 @@ const plugin: GraphQLSchemaPlugin = { resolvers: { FmQuery: { getPreSignedPostPayload: async (_, args: any, context) => { + const identityContext = context.container.resolve(IdentityContext); + const getSettings = context.container.resolve(GetSettingsUseCase); + try { - await checkPermissions(context, { rwd: "w" }); + await checkPermissions(identityContext, { rwd: "w" }); const data = args.data as PresignedPostPayloadData; - const settings = await context.fileManager.getSettings(); - if (!settings) { - throw new WebinyError( - "Missing File Manager Settings.", - "FILE_MANAGER_SETTINGS_ERROR", - { file: data } - ); - } + const settingsResult = await getSettings.execute(); + const settings = settingsResult.value; const normalizer = createFileNormalizerFromContext(context); const presignedPayload = await getPresignedPostPayload( @@ -137,19 +132,15 @@ const plugin: GraphQLSchemaPlugin = { } }, getPreSignedPostPayloads: async (_, args, context) => { - await checkPermissions(context, { rwd: "w" }); + const identityContext = context.container.resolve(IdentityContext); + const getSettings = context.container.resolve(GetSettingsUseCase); + await checkPermissions(identityContext, { rwd: "w" }); const files = args.data as PresignedPostPayloadData[]; try { - const settings = await context.fileManager.getSettings(); - if (!settings) { - throw new WebinyError( - "Missing File Manager Settings.", - "FILE_MANAGER_SETTINGS_ERROR", - { files } - ); - } + const settingsResult = await getSettings.execute(); + const settings = settingsResult.value; const normalizer = createFileNormalizerFromContext(context); @@ -172,7 +163,8 @@ const plugin: GraphQLSchemaPlugin = { }, FmMutation: { createMultiPartUpload: async (_, args, context) => { - await checkPermissions(context, { rwd: "w" }); + const identityContext = context.container.resolve(IdentityContext); + await checkPermissions(identityContext, { rwd: "w" }); const s3Client = new S3({ region: process.env.AWS_REGION @@ -201,7 +193,8 @@ const plugin: GraphQLSchemaPlugin = { } }, completeMultiPartUpload: async (_, args, context) => { - await checkPermissions(context, { rwd: "w" }); + const identityContext = context.container.resolve(IdentityContext); + await checkPermissions(identityContext, { rwd: "w" }); const s3Client = new S3({ region: process.env.AWS_REGION @@ -229,7 +222,5 @@ const plugin: GraphQLSchemaPlugin = { } } } - } + }); }; - -export default plugin; diff --git a/packages/api-file-manager-s3/src/index.ts b/packages/api-file-manager-s3/src/index.ts index e472ec4f541..97c1300239e 100644 --- a/packages/api-file-manager-s3/src/index.ts +++ b/packages/api-file-manager-s3/src/index.ts @@ -1,10 +1,26 @@ -import graphqlFileStorageS3 from "./plugins/graphqlFileStorageS3.js"; -import fileStorageS3 from "./plugins/fileStorageS3.js"; -import { addFileMetadata } from "./plugins/addFileMetadata.js"; +import { ContextPlugin } from "@webiny/api"; +import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; +import { createS3GraphQLSchema } from "./graphql/schema.js"; import { flushCdnCache } from "~/flushCdnCache/index.js"; - +import { DeleteFileFromBucketFeature } from "~/features/DeleteFileFromBucket/feature.js"; +import { WriteFileMetadataFeature } from "~/features/WriteFileMetadata/feature.js"; +import { ApplyThreatScanningFeature } from "~/enterprise/ApplyThreatScanning/feature.js"; +import { FlushCacheFeature } from "~/features/FlushCache/feature.js"; export { createFileUploadModifier } from "./utils/FileUploadModifier.js"; export { createAssetDelivery } from "./assetDelivery/createAssetDelivery.js"; export { createCustomAssetDelivery } from "./assetDelivery/createCustomAssetDelivery.js"; -export default () => [fileStorageS3(), graphqlFileStorageS3, addFileMetadata(), flushCdnCache()]; +export const createFileManagerS3 = () => [ + new ContextPlugin(context => { + FlushCacheFeature.register(context.container); + DeleteFileFromBucketFeature.register(context.container); + WriteFileMetadataFeature.register(context.container); + + const wcp = context.container.resolve(WcpContext); + if (wcp.canUseFileManagerThreatDetection()) { + ApplyThreatScanningFeature.register(context.container); + } + }), + createS3GraphQLSchema(), + flushCdnCache() +]; diff --git a/packages/api-file-manager-s3/src/plugins/fileStorageS3.ts b/packages/api-file-manager-s3/src/plugins/fileStorageS3.ts deleted file mode 100644 index 8220edc353f..00000000000 --- a/packages/api-file-manager-s3/src/plugins/fileStorageS3.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; -import { FilePhysicalStoragePlugin } from "@webiny/api-file-manager/plugins/FilePhysicalStoragePlugin.js"; -import { getPresignedPostPayload } from "~/utils/getPresignedPostPayload.js"; -import uploadFileToS3 from "../utils/uploadFileToS3.js"; -import { ContextPlugin } from "@webiny/api"; -import { createFileNormalizerFromContext } from "~/utils/createFileNormalizerFromContext.js"; -import type { PresignedPostPayloadData } from "~/types.js"; - -const S3_BUCKET = process.env.S3_BUCKET; - -export default () => { - /** - * We need to extend the type for FilePhysicalStoragePlugin. - * Otherwise, the `getPresignedPostPayload` doesn't know it has all required values in params. - */ - return new ContextPlugin(context => { - context.plugins.register( - new FilePhysicalStoragePlugin({ - upload: async params => { - const { settings, buffer, ...data } = params; - - const normalizer = createFileNormalizerFromContext(context); - - const { data: preSignedPostPayload, file } = await getPresignedPostPayload( - await normalizer.normalizeFile(data as PresignedPostPayloadData), - settings - ); - - const response = await uploadFileToS3(buffer, preSignedPostPayload); - if (!response.ok) { - throw Error("Unable to upload file."); - } - - return { - data: preSignedPostPayload, - file - }; - }, - delete: async params => { - const { key } = params; - const s3 = new S3(); - - if (!key || !S3_BUCKET) { - return; - } - - await s3.deleteObject({ - Bucket: S3_BUCKET, - Key: key - }); - } - }) - ); - }); -}; diff --git a/packages/api-file-manager-s3/tsconfig.build.json b/packages/api-file-manager-s3/tsconfig.build.json index c95964e039a..26f30da0183 100644 --- a/packages/api-file-manager-s3/tsconfig.build.json +++ b/packages/api-file-manager-s3/tsconfig.build.json @@ -153,8 +153,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/api-file-manager-s3/tsconfig.json b/packages/api-file-manager-s3/tsconfig.json index 61db72583c8..b6ce8d8cd42 100644 --- a/packages/api-file-manager-s3/tsconfig.json +++ b/packages/api-file-manager-s3/tsconfig.json @@ -153,8 +153,8 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/api-file-manager/__tests__/file.extensions.test.ts b/packages/api-file-manager/__tests__/file.extensions.test.ts index e88b46db0ba..37e2665526a 100644 --- a/packages/api-file-manager/__tests__/file.extensions.test.ts +++ b/packages/api-file-manager/__tests__/file.extensions.test.ts @@ -3,7 +3,8 @@ import useGqlHandler from "~tests/utils/useGqlHandler"; import { createFileModelModifier } from "~/modelModifier/CmsModelModifier"; import { fileAData } from "./mocks/files"; -describe("File Model Extensions", () => { +// TODO: enable once the new extensions API is in place +describe.skip("File Model Extensions", () => { const { listFiles, createFile } = useGqlHandler({ plugins: [ // Add custom fields that will be assigned to the `extensions` object field. diff --git a/packages/api-file-manager/__tests__/file.lifecycle.test.ts b/packages/api-file-manager/__tests__/file.lifecycle.test.ts index d60379cb859..4ac243b66f1 100644 --- a/packages/api-file-manager/__tests__/file.lifecycle.test.ts +++ b/packages/api-file-manager/__tests__/file.lifecycle.test.ts @@ -4,8 +4,6 @@ import useGqlHandler from "~tests/utils/useGqlHandler"; import { assignFileLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; import { ROOT_FOLDER } from "~/contants"; -const WEBINY_VERSION = process.env.WEBINY_VERSION; - const TAG = "webiny"; const id = mdbid(); @@ -27,8 +25,6 @@ describe("File lifecycle events", () => { const hookParamsExpected = { id: expect.any(String), - createdOn: expect.stringMatching(/^20/), - savedOn: expect.stringMatching(/^20/), createdBy: { id: "12345678", displayName: "John Doe", @@ -38,12 +34,7 @@ describe("File lifecycle events", () => { id: "12345678", displayName: "John Doe", type: "admin" - }, - tenant: "root", - meta: { - private: false - }, - webinyVersion: WEBINY_VERSION + } }; beforeEach(() => { @@ -89,8 +80,7 @@ describe("File lifecycle events", () => { ...hookParamsExpected, location: { folderId: ROOT_FOLDER - }, - savedOn: expect.any(String) + } } }); const afterCreate = tracker.getLast("file:beforeCreate"); @@ -100,8 +90,7 @@ describe("File lifecycle events", () => { ...hookParamsExpected, location: { folderId: ROOT_FOLDER - }, - savedOn: expect.any(String) + } } }); }); @@ -225,8 +214,8 @@ describe("File lifecycle events", () => { file: { ...fileData, ...hookParamsExpected, - modifiedOn: null, - modifiedBy: null, + modifiedOn: undefined, + modifiedBy: undefined, location: { folderId: ROOT_FOLDER }, @@ -238,8 +227,8 @@ describe("File lifecycle events", () => { file: { ...fileData, ...hookParamsExpected, - modifiedOn: null, - modifiedBy: null, + modifiedOn: undefined, + modifiedBy: undefined, location: { folderId: ROOT_FOLDER }, @@ -286,13 +275,9 @@ describe("File lifecycle events", () => { files: [ { ...fileData, - ...hookParamsExpected, - modifiedOn: null, - modifiedBy: null, location: { folderId: ROOT_FOLDER - }, - savedOn: expect.any(String) + } } ] }); @@ -302,12 +287,9 @@ describe("File lifecycle events", () => { { ...fileData, ...hookParamsExpected, - modifiedOn: null, - modifiedBy: null, location: { folderId: ROOT_FOLDER - }, - savedOn: expect.any(String) + } } ] }); diff --git a/packages/api-file-manager/__tests__/fileModelModifier.test.ts b/packages/api-file-manager/__tests__/fileModelModifier.test.ts index e43697215d8..5b555486833 100644 --- a/packages/api-file-manager/__tests__/fileModelModifier.test.ts +++ b/packages/api-file-manager/__tests__/fileModelModifier.test.ts @@ -3,7 +3,8 @@ import { useHandler } from "./utils/useHandler"; import { createFileModelModifier } from "~/index"; import { fileAData, fileBData } from "./mocks/files"; -describe("File Model Modifier test", () => { +// TODO: enable this when model modifiers are working again +describe.skip("File Model Modifier test", () => { test("should add custom fields to `extensions` object field", async () => { const { handler } = useHandler({ plugins: [ diff --git a/packages/api-file-manager/__tests__/fileSchema.test.ts b/packages/api-file-manager/__tests__/fileSchema.test.ts index cc51fc8f9b7..c9e00936833 100644 --- a/packages/api-file-manager/__tests__/fileSchema.test.ts +++ b/packages/api-file-manager/__tests__/fileSchema.test.ts @@ -4,7 +4,7 @@ import { useHandler } from "./utils/useHandler.js"; import { createFilesTypeDefs } from "~/graphql/createFilesTypeDefs.js"; import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; -import fileSdlSnapshot from "./mocks/file.sdl.js"; +import fileSdlSnapshot from "./mocks/fileWithoutExtensions.sdl.js"; import { createFileModelModifier } from "~/modelModifier/CmsModelModifier.js"; describe("File Model Modifier test", () => { diff --git a/packages/api-file-manager/__tests__/filesSecurity.test.ts b/packages/api-file-manager/__tests__/filesSecurity.test.ts index b3b8023ade9..45ac89f49e1 100644 --- a/packages/api-file-manager/__tests__/filesSecurity.test.ts +++ b/packages/api-file-manager/__tests__/filesSecurity.test.ts @@ -26,9 +26,9 @@ const NOT_AUTHORIZED_RESPONSE = (operation: string) => ({ [operation]: { data: null, error: { - code: "NOT_AUTHORIZED", + code: "FileManager/File/NotAuthorizedError", data: null, - message: "Not authorized!" + message: "Not authorized." } } } @@ -51,7 +51,7 @@ type IdentityPermissions = Array<[SecurityPermission[], IdentityData | null]>; describe("Files Security Test", { timeout: 10_000 }, () => { const { createFile, createFiles, until } = useGqlHandler({ - permissions: [{ name: "content.i18n" }, { name: "fm.*" }], + permissions: [{ name: "fm.*" }], identity: identityA }); @@ -89,9 +89,9 @@ describe("Files Security Test", { timeout: 10_000 }, () => { data: null, meta: null, error: { - code: "NOT_AUTHORIZED", + code: "FileManager/File/NotAuthorizedError", data: null, - message: "Not authorized!" + message: "Not authorized." } } } @@ -100,11 +100,11 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } const sufficientPermissionsAll: IdentityPermissions = [ - [[{ name: "content.i18n" }, { name: "fm.file" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "r" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rw" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rwd" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.*" }], identityA] + [[{ name: "fm.file" }], identityA], + [[{ name: "fm.file", rwd: "r" }], identityA], + [[{ name: "fm.file", rwd: "rw" }], identityA], + [[{ name: "fm.file", rwd: "rwd" }], identityA], + [[{ name: "fm.*" }], identityA] ]; for (let i = 0; i < sufficientPermissionsAll.length; i++) { @@ -141,7 +141,7 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } let identityAHandler = useGqlHandler({ - permissions: [{ name: "content.i18n" }, { name: "fm.file", own: true }], + permissions: [{ name: "fm.file", own: true }], identity: identityA }); @@ -163,7 +163,7 @@ describe("Files Security Test", { timeout: 10_000 }, () => { }); identityAHandler = useGqlHandler({ - permissions: [{ name: "content.i18n" }, { name: "fm.file", own: true }], + permissions: [{ name: "fm.file", own: true }], identity: identityB }); @@ -202,11 +202,11 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } const sufficientPermissions: IdentityPermissions = [ - [[{ name: "content.i18n" }, { name: "fm.file" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", own: true }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "w" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rw" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rwd" }], identityA] + [[{ name: "fm.file" }], identityA], + [[{ name: "fm.file", own: true }], identityA], + [[{ name: "fm.file", rwd: "w" }], identityA], + [[{ name: "fm.file", rwd: "rw" }], identityA], + [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; for (let i = 0; i < sufficientPermissions.length; i++) { @@ -249,11 +249,11 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } ); const sufficientPermissions: IdentityPermissions = [ - [[{ name: "content.i18n" }, { name: "fm.file" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", own: true }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "w" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rw" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rwd" }], identityA] + [[{ name: "fm.file" }], identityA], + [[{ name: "fm.file", own: true }], identityA], + [[{ name: "fm.file", rwd: "w" }], identityA], + [[{ name: "fm.file", rwd: "rw" }], identityA], + [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; test.each(sufficientPermissions)( @@ -301,11 +301,11 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } const sufficientPermissions: IdentityPermissions = [ - [[{ name: "content.i18n" }, { name: "fm.file" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", own: true }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "w" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rw" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rwd" }], identityA] + [[{ name: "fm.file" }], identityA], + [[{ name: "fm.file", own: true }], identityA], + [[{ name: "fm.file", rwd: "w" }], identityA], + [[{ name: "fm.file", rwd: "rw" }], identityA], + [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; for (let i = 0; i < sufficientPermissions.length; i++) { @@ -347,11 +347,11 @@ describe("Files Security Test", { timeout: 10_000 }, () => { } const sufficientPermissions: IdentityPermissions = [ - [[{ name: "content.i18n" }, { name: "fm.file" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", own: true }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "r" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rw" }], identityA], - [[{ name: "content.i18n" }, { name: "fm.file", rwd: "rwd" }], identityA] + [[{ name: "fm.file" }], identityA], + [[{ name: "fm.file", own: true }], identityA], + [[{ name: "fm.file", rwd: "r" }], identityA], + [[{ name: "fm.file", rwd: "rw" }], identityA], + [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; for (let i = 0; i < sufficientPermissions.length; i++) { diff --git a/packages/api-file-manager/__tests__/filesSettings.lifecycle.test.ts b/packages/api-file-manager/__tests__/filesSettings.lifecycle.test.ts deleted file mode 100644 index c2aa6cd0882..00000000000 --- a/packages/api-file-manager/__tests__/filesSettings.lifecycle.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, test, expect, beforeEach } from "vitest"; -import useGqlHandler from "~tests/utils/useGqlHandler"; -import { assignSettingsLifecycleEvents, tracker } from "./mocks/lifecycleEvents"; - -describe.skip("Files settings lifecycle events", () => { - const { updateSettings } = useGqlHandler({ - plugins: [assignSettingsLifecycleEvents()] - }); - - const hookParamsExpected = { - srcPrefix: "https://0c6fb883-webiny-latest-files.s3.amazonaws.com/", - tenant: "root" - }; - - beforeEach(() => { - tracker.reset(); - }); - - test(`it should call "beforeUpdate" and "afterUpdate" methods`, async () => { - const settingsData = getSettingsResponse.data.fileManager.getSettings.data; - - const [updateResponse] = await updateSettings({ - data: { uploadMinFileSize: 1024 } - }); - expect(updateResponse).toEqual({ - data: { - fileManager: { - updateSettings: { - data: { - ...settingsData, - uploadMinFileSize: 1024 - }, - error: null - } - } - } - }); - /** - * After that we expect that lifecycle method was triggered. - */ - expect(tracker.getExecuted("settings:beforeUpdate")).toEqual(1); - expect(tracker.getExecuted("settings:afterUpdate")).toEqual(1); - /** - * Parameters that were received in the lifecycle hooks must be valid as well. - */ - const beforeUpdate = tracker.getLast("settings:beforeUpdate"); - expect(beforeUpdate && beforeUpdate.params[0]).toEqual({ - input: { uploadMinFileSize: 1024 }, - original: { ...settingsData, ...hookParamsExpected }, - settings: { - ...settingsData, - ...hookParamsExpected, - uploadMinFileSize: 1024 - } - }); - const afterUpdate = tracker.getLast("settings:afterUpdate"); - expect(afterUpdate && afterUpdate.params[0]).toEqual({ - input: { uploadMinFileSize: 1024 }, - original: { ...settingsData, ...hookParamsExpected }, - settings: { - ...settingsData, - ...hookParamsExpected, - uploadMinFileSize: 1024 - } - }); - }); -}); diff --git a/packages/api-file-manager/__tests__/filesSettings.test.ts b/packages/api-file-manager/__tests__/filesSettings.test.ts index e6a82fc9483..dd5f4bf44bf 100644 --- a/packages/api-file-manager/__tests__/filesSettings.test.ts +++ b/packages/api-file-manager/__tests__/filesSettings.test.ts @@ -7,7 +7,7 @@ const identityA: IdentityData = { type: "test", displayName: "Aa" }; -describe.skip("Files settings test", () => { +describe("Files settings test", () => { const { getSettings, updateSettings } = useGqlHandler({ identity: identityA }); @@ -38,7 +38,7 @@ describe.skip("Files settings test", () => { updateSettings: { data: null, error: { - code: "VALIDATION_FAILED_INVALID_FIELDS", + code: "FileManager/Settings/ValidationError", message: "Validation failed.", data: { invalidFields: { @@ -67,7 +67,7 @@ describe.skip("Files settings test", () => { updateSettings: { data: null, error: { - code: "VALIDATION_FAILED_INVALID_FIELDS", + code: "FileManager/Settings/ValidationError", message: "Validation failed.", data: { invalidFields: { diff --git a/packages/api-file-manager/__tests__/mocks/fileWithoutExtensions.sdl.ts b/packages/api-file-manager/__tests__/mocks/fileWithoutExtensions.sdl.ts new file mode 100644 index 00000000000..430e670dbf6 --- /dev/null +++ b/packages/api-file-manager/__tests__/mocks/fileWithoutExtensions.sdl.ts @@ -0,0 +1,480 @@ +export default /* GraphQL */ ` + type FmFile_Location { + folderId: String + } + + input FmFile_LocationWhereInput { + folderId: String + folderId_not: String + folderId_in: [String] + folderId_not_in: [String] + folderId_contains: String + folderId_not_contains: String + folderId_startsWith: String + folderId_not_startsWith: String + } + + type FmFile_Meta { + private: Boolean + width: Number + height: Number + originalKey: String + } + + input FmFile_MetaWhereInput { + private: Boolean + private_not: Boolean + + width: Number + width_not: Number + width_in: [Number] + width_not_in: [Number] + width_lt: Number + width_lte: Number + width_gt: Number + width_gte: Number + # there must be two numbers sent in the array + width_between: [Number!] + # there must be two numbers sent in the array + width_not_between: [Number!] + + height: Number + height_not: Number + height_in: [Number] + height_not_in: [Number] + height_lt: Number + height_lte: Number + height_gt: Number + height_gte: Number + # there must be two numbers sent in the array + height_between: [Number!] + # there must be two numbers sent in the array + height_not_between: [Number!] + + originalKey: String + originalKey_not: String + originalKey_in: [String] + originalKey_not_in: [String] + originalKey_contains: String + originalKey_not_contains: String + originalKey_startsWith: String + originalKey_not_startsWith: String + } + + type FmFile_AccessControl { + type: String + } + + input FmFile_AccessControlWhereInput { + type: String + type_not: String + type_in: [String] + type_not_in: [String] + type_contains: String + type_not_contains: String + type_startsWith: String + type_not_startsWith: String + } + + type FmFile { + id: ID! + createdOn: DateTime! + modifiedOn: DateTime + savedOn: DateTime! + createdBy: FmCreatedBy! + modifiedBy: FmCreatedBy + savedBy: FmCreatedBy! + src: String + location: FmFile_Location + name: String + key: String + type: String + size: Number + meta: FmFile_Meta + tags: [String] + aliases: [String] + accessControl: FmFile_AccessControl + } + + input FmFile_LocationInput { + folderId: String + } + + input FmFile_MetaInput { + private: Boolean + width: Number + height: Number + originalKey: String + } + + input FmFile_AccessControlInput { + type: String + } + + input FmCreatedByInput { + id: ID! + displayName: String! + type: String! + } + + input FmFileCreateInput { + id: ID! + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + createdBy: FmCreatedByInput + modifiedBy: FmCreatedByInput + savedBy: FmCreatedByInput + location: FmFile_LocationInput + name: String + key: String + type: String + size: Number + meta: FmFile_MetaInput + tags: [String!] + aliases: [String!] + accessControl: FmFile_AccessControlInput + } + + input FmFileUpdateInput { + createdOn: DateTime + modifiedOn: DateTime + savedOn: DateTime + createdBy: FmCreatedByInput + modifiedBy: FmCreatedByInput + savedBy: FmCreatedByInput + location: FmFile_LocationInput + name: String + key: String + type: String + size: Number + meta: FmFile_MetaInput + tags: [String] + aliases: [String] + accessControl: FmFile_AccessControlInput + } + + type FmFileResponse { + data: FmFile + error: FmError + } + + input FmFileListWhereInput { + id: ID + id_not: ID + id_in: [ID!] + id_not_in: [ID!] + createdOn: DateTime + createdOn_gt: DateTime + createdOn_gte: DateTime + createdOn_lt: DateTime + createdOn_lte: DateTime + createdOn_between: [DateTime!] + createdOn_not_between: [DateTime!] + modifiedOn: DateTime + modifiedOn_gt: DateTime + modifiedOn_gte: DateTime + modifiedOn_lt: DateTime + modifiedOn_lte: DateTime + modifiedOn_between: [DateTime!] + modifiedOn_not_between: [DateTime!] + savedOn: DateTime + savedOn_gt: DateTime + savedOn_gte: DateTime + savedOn_lt: DateTime + savedOn_lte: DateTime + savedOn_between: [DateTime!] + savedOn_not_between: [DateTime!] + deletedOn: DateTime + deletedOn_gt: DateTime + deletedOn_gte: DateTime + deletedOn_lt: DateTime + deletedOn_lte: DateTime + deletedOn_between: [DateTime!] + deletedOn_not_between: [DateTime!] + restoredOn: DateTime + restoredOn_gt: DateTime + restoredOn_gte: DateTime + restoredOn_lt: DateTime + restoredOn_lte: DateTime + restoredOn_between: [DateTime!] + restoredOn_not_between: [DateTime!] + firstPublishedOn: DateTime + firstPublishedOn_gt: DateTime + firstPublishedOn_gte: DateTime + firstPublishedOn_lt: DateTime + firstPublishedOn_lte: DateTime + firstPublishedOn_between: [DateTime!] + firstPublishedOn_not_between: [DateTime!] + lastPublishedOn: DateTime + lastPublishedOn_gt: DateTime + lastPublishedOn_gte: DateTime + lastPublishedOn_lt: DateTime + lastPublishedOn_lte: DateTime + lastPublishedOn_between: [DateTime!] + lastPublishedOn_not_between: [DateTime!] + createdBy: ID + createdBy_not: ID + createdBy_in: [ID!] + createdBy_not_in: [ID!] + modifiedBy: ID + modifiedBy_not: ID + modifiedBy_in: [ID!] + modifiedBy_not_in: [ID!] + savedBy: ID + savedBy_not: ID + savedBy_in: [ID!] + savedBy_not_in: [ID!] + deletedBy: ID + deletedBy_not: ID + deletedBy_in: [ID!] + deletedBy_not_in: [ID!] + restoredBy: ID + restoredBy_not: ID + restoredBy_in: [ID!] + restoredBy_not_in: [ID!] + firstPublishedBy: ID + firstPublishedBy_not: ID + firstPublishedBy_in: [ID!] + firstPublishedBy_not_in: [ID!] + lastPublishedBy: ID + lastPublishedBy_not: ID + lastPublishedBy_in: [ID!] + lastPublishedBy_not_in: [ID!] + revisionCreatedOn: DateTime + revisionCreatedOn_gt: DateTime + revisionCreatedOn_gte: DateTime + revisionCreatedOn_lt: DateTime + revisionCreatedOn_lte: DateTime + revisionCreatedOn_between: [DateTime!] + revisionCreatedOn_not_between: [DateTime!] + revisionModifiedOn: DateTime + revisionModifiedOn_gt: DateTime + revisionModifiedOn_gte: DateTime + revisionModifiedOn_lt: DateTime + revisionModifiedOn_lte: DateTime + revisionModifiedOn_between: [DateTime!] + revisionModifiedOn_not_between: [DateTime!] + revisionSavedOn: DateTime + revisionSavedOn_gt: DateTime + revisionSavedOn_gte: DateTime + revisionSavedOn_lt: DateTime + revisionSavedOn_lte: DateTime + revisionSavedOn_between: [DateTime!] + revisionSavedOn_not_between: [DateTime!] + revisionDeletedOn: DateTime + revisionDeletedOn_gt: DateTime + revisionDeletedOn_gte: DateTime + revisionDeletedOn_lt: DateTime + revisionDeletedOn_lte: DateTime + revisionDeletedOn_between: [DateTime!] + revisionDeletedOn_not_between: [DateTime!] + revisionRestoredOn: DateTime + revisionRestoredOn_gt: DateTime + revisionRestoredOn_gte: DateTime + revisionRestoredOn_lt: DateTime + revisionRestoredOn_lte: DateTime + revisionRestoredOn_between: [DateTime!] + revisionRestoredOn_not_between: [DateTime!] + revisionFirstPublishedOn: DateTime + revisionFirstPublishedOn_gt: DateTime + revisionFirstPublishedOn_gte: DateTime + revisionFirstPublishedOn_lt: DateTime + revisionFirstPublishedOn_lte: DateTime + revisionFirstPublishedOn_between: [DateTime!] + revisionFirstPublishedOn_not_between: [DateTime!] + revisionLastPublishedOn: DateTime + revisionLastPublishedOn_gt: DateTime + revisionLastPublishedOn_gte: DateTime + revisionLastPublishedOn_lt: DateTime + revisionLastPublishedOn_lte: DateTime + revisionLastPublishedOn_between: [DateTime!] + revisionLastPublishedOn_not_between: [DateTime!] + revisionCreatedBy: ID + revisionCreatedBy_not: ID + revisionCreatedBy_in: [ID!] + revisionCreatedBy_not_in: [ID!] + revisionModifiedBy: ID + revisionModifiedBy_not: ID + revisionModifiedBy_in: [ID!] + revisionModifiedBy_not_in: [ID!] + revisionSavedBy: ID + revisionSavedBy_not: ID + revisionSavedBy_in: [ID!] + revisionSavedBy_not_in: [ID!] + revisionDeletedBy: ID + revisionDeletedBy_not: ID + revisionDeletedBy_in: [ID!] + revisionDeletedBy_not_in: [ID!] + revisionRestoredBy: ID + revisionRestoredBy_not: ID + revisionRestoredBy_in: [ID!] + revisionRestoredBy_not_in: [ID!] + revisionFirstPublishedBy: ID + revisionFirstPublishedBy_not: ID + revisionFirstPublishedBy_in: [ID!] + revisionFirstPublishedBy_not_in: [ID!] + revisionLastPublishedBy: ID + revisionLastPublishedBy_not: ID + revisionLastPublishedBy_in: [ID!] + revisionLastPublishedBy_not_in: [ID!] + location: FmFile_LocationWhereInput + + name: String + name_not: String + name_in: [String] + name_not_in: [String] + name_contains: String + name_not_contains: String + name_startsWith: String + name_not_startsWith: String + + key: String + key_not: String + key_in: [String] + key_not_in: [String] + key_contains: String + key_not_contains: String + key_startsWith: String + key_not_startsWith: String + + type: String + type_not: String + type_in: [String] + type_not_in: [String] + type_contains: String + type_not_contains: String + type_startsWith: String + type_not_startsWith: String + + size: Number + size_not: Number + size_in: [Number] + size_not_in: [Number] + size_lt: Number + size_lte: Number + size_gt: Number + size_gte: Number + # there must be two numbers sent in the array + size_between: [Number!] + # there must be two numbers sent in the array + size_not_between: [Number!] + + meta: FmFile_MetaWhereInput + + tags: String + tags_not: String + tags_in: [String] + tags_not_in: [String] + tags_contains: String + tags_not_contains: String + tags_startsWith: String + tags_not_startsWith: String + + aliases: String + aliases_not: String + aliases_in: [String] + aliases_not_in: [String] + aliases_contains: String + aliases_not_contains: String + aliases_startsWith: String + aliases_not_startsWith: String + + accessControl: FmFile_AccessControlWhereInput + AND: [FmFileListWhereInput!] + OR: [FmFileListWhereInput!] + } + + type FmFileListResponse { + data: [FmFile!] + error: FmError + meta: FmListMeta + } + + enum FmFileListSorter { + id_ASC + id_DESC + createdOn_ASC + createdOn_DESC + modifiedOn_ASC + modifiedOn_DESC + savedOn_ASC + savedOn_DESC + deletedOn_ASC + deletedOn_DESC + restoredOn_ASC + restoredOn_DESC + firstPublishedOn_ASC + firstPublishedOn_DESC + lastPublishedOn_ASC + lastPublishedOn_DESC + revisionCreatedOn_ASC + revisionCreatedOn_DESC + revisionModifiedOn_ASC + revisionModifiedOn_DESC + revisionSavedOn_ASC + revisionSavedOn_DESC + revisionDeletedOn_ASC + revisionDeletedOn_DESC + revisionRestoredOn_ASC + revisionRestoredOn_DESC + revisionFirstPublishedOn_ASC + revisionFirstPublishedOn_DESC + revisionLastPublishedOn_ASC + revisionLastPublishedOn_DESC + name_ASC + name_DESC + key_ASC + key_DESC + type_ASC + type_DESC + size_ASC + size_DESC + } + + input FmTagsListWhereInput { + createdBy: String + tags_startsWith: String + tags_not_startsWith: String + } + + type FmTag { + tag: String! + count: Number! + } + + type FmTagsListResponse { + data: [FmTag!] + error: FmError + } + + type FmCreateFilesResponse { + data: [FmFile!] + error: FmError + } + + type FmFileModelResponse { + data: JSON + error: FmError + } + + extend type FmQuery { + getFileModel: FmFileModelResponse! + getFile(id: ID!): FmFileResponse! + listFiles( + search: String + where: FmFileListWhereInput + limit: Int + after: String + sort: [FmFileListSorter!] + ): FmFileListResponse! + listTags(where: FmTagsListWhereInput): FmTagsListResponse! + } + + extend type FmMutation { + createFile(data: FmFileCreateInput!): FmFileResponse! + createFiles(data: [FmFileCreateInput!]!): FmCreateFilesResponse! + updateFile(id: ID!, data: FmFileUpdateInput!): FmFileResponse! + deleteFile(id: ID!): FmBooleanResponse! + } +`; diff --git a/packages/api-file-manager/__tests__/mocks/lifecycleEvents.ts b/packages/api-file-manager/__tests__/mocks/lifecycleEvents.ts index 3d69182a968..a871d533505 100644 --- a/packages/api-file-manager/__tests__/mocks/lifecycleEvents.ts +++ b/packages/api-file-manager/__tests__/mocks/lifecycleEvents.ts @@ -1,48 +1,72 @@ import { ContextPlugin } from "@webiny/api"; import { LifecycleEventTracker } from "@webiny/project-utils/testing/helpers/lifecycleTracker"; -import type { FileManagerContext } from "~/types"; +import { + FileAfterCreateHandler, + FileBeforeCreateHandler +} from "~/features/file/CreateFile/events.js"; +import { + FileAfterBatchCreateHandler, + FileBeforeBatchCreateHandler +} from "~/features/file/CreateFilesInBatch/events.js"; +import { + FileAfterUpdateHandler, + FileBeforeUpdateHandler +} from "~/features/file/UpdateFile/events.js"; +import { + FileAfterDeleteHandler, + FileBeforeDeleteHandler +} from "~/features/file/DeleteFile/events.js"; export const tracker = new LifecycleEventTracker(); export const assignFileLifecycleEvents = () => { - return new ContextPlugin(async context => { - context.fileManager.onFileBeforeCreate.subscribe(async params => { - tracker.track("file:beforeCreate", params); - }); - context.fileManager.onFileAfterCreate.subscribe(async params => { - tracker.track("file:afterCreate", params); + return new ContextPlugin(async context => { + context.container.registerInstance(FileBeforeCreateHandler, { + handle: event => { + tracker.track("file:beforeCreate", event.payload); + } }); - context.fileManager.onFileBeforeBatchCreate.subscribe(async params => { - tracker.track("file:beforeBatchCreate", params); - }); - context.fileManager.onFileAfterBatchCreate.subscribe(async params => { - tracker.track("file:afterBatchCreate", params); + context.container.registerInstance(FileAfterCreateHandler, { + handle: event => { + tracker.track("file:afterCreate", event.payload); + } }); - context.fileManager.onFileBeforeUpdate.subscribe(async params => { - tracker.track("file:beforeUpdate", params); + context.container.registerInstance(FileBeforeBatchCreateHandler, { + handle: event => { + tracker.track("file:beforeBatchCreate", event.payload); + } }); - context.fileManager.onFileAfterUpdate.subscribe(async params => { - tracker.track("file:afterUpdate", params); + + context.container.registerInstance(FileAfterBatchCreateHandler, { + handle: event => { + tracker.track("file:afterBatchCreate", event.payload); + } }); - context.fileManager.onFileBeforeDelete.subscribe(async params => { - tracker.track("file:beforeDelete", params); + context.container.registerInstance(FileBeforeUpdateHandler, { + handle: event => { + tracker.track("file:beforeUpdate", event.payload); + } }); - context.fileManager.onFileAfterDelete.subscribe(async params => { - tracker.track("file:afterDelete", params); + + context.container.registerInstance(FileAfterUpdateHandler, { + handle: event => { + tracker.track("file:afterUpdate", event.payload); + } }); - }); -}; -export const assignSettingsLifecycleEvents = () => { - return new ContextPlugin(async context => { - context.fileManager.onSettingsBeforeUpdate.subscribe(async params => { - tracker.track("settings:beforeUpdate", params); + context.container.registerInstance(FileBeforeDeleteHandler, { + handle: event => { + tracker.track("file:beforeDelete", event.payload); + } }); - context.fileManager.onSettingsAfterUpdate.subscribe(async params => { - tracker.track("settings:afterUpdate", params); + + context.container.registerInstance(FileAfterDeleteHandler, { + handle: event => { + tracker.track("file:afterDelete", event.payload); + } }); }); }; diff --git a/packages/api-file-manager/__tests__/utils/plugins.ts b/packages/api-file-manager/__tests__/utils/plugins.ts index b70bb15b2de..c0ac5d26fe1 100644 --- a/packages/api-file-manager/__tests__/utils/plugins.ts +++ b/packages/api-file-manager/__tests__/utils/plugins.ts @@ -5,11 +5,7 @@ import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; -import { - createFileManagerContext, - createFileManagerGraphQL, - FilePhysicalStoragePlugin -} from "~/index"; +import { createFileManagerContext, createFileManagerGraphQL } from "~/index"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import type { FileManagerStorageOperations } from "~/types"; import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; @@ -51,18 +47,8 @@ export const handlerPlugins = (params: HandlerParams) => { }), createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), createHeadlessCmsGraphQL(), - createFileManagerContext({ - storageOperations: fileManagerStorage.storageOperations - }), + createFileManagerContext(), createFileManagerGraphQL(), - /** - * Mock physical file storage plugin. - */ - new FilePhysicalStoragePlugin({ - upload: async () => {}, - - delete: async () => {} - }), /** * Make sure we dont have undefined plugins value. */ diff --git a/packages/api-file-manager/src/FileManagerContextSetup.ts b/packages/api-file-manager/src/FileManagerContextSetup.ts deleted file mode 100644 index edce4c24a4f..00000000000 --- a/packages/api-file-manager/src/FileManagerContextSetup.ts +++ /dev/null @@ -1,115 +0,0 @@ -// @ts-nocheck Being removed -import type { FileAliasesStorageOperations, FilePermission } from "~/types.js"; -import { type FileManagerContext, type SettingsPermission } from "~/types.js"; -import type { FileManagerConfig } from "~/createFileManager/types.js"; -import { createFileManager } from "~/createFileManager/index.js"; -import { FileStorage } from "~/storage/FileStorage.js"; -import WebinyError from "@webiny/error"; -import { createFileModel, FILE_MODEL_ID } from "~/domain/file/fileModel.js"; -import { CmsFilesStorage } from "~/cmsFileStorage/CmsFilesStorage.js"; -import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; -import { FilesPermissions } from "~/createFileManager/permissions/FilesPermissions.js"; -import { SettingsPermissions } from "~/createFileManager/permissions/SettingsPermissions.js"; -import type { SecurityPermission } from "@webiny/api-core/types/security.js"; -import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; -import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; - -export class FileManagerContextSetup { - private readonly context: FileManagerContext; - - constructor(context: FileManagerContext) { - this.context = context; - } - - async setupContext(storageOperations: FileManagerConfig["storageOperations"]) { - const filesPermissions = new FilesPermissions({ - getIdentity: () => { - const identityContext = this.context.container.resolve(IdentityContext); - return identityContext.getIdentity(); - }, - getPermissions: () => { - const identityContext = this.context.container.resolve(IdentityContext); - return identityContext.getPermissions("fm.file"); - }, - fullAccessPermissionName: "fm.*" - }); - - const settingsPermissions = new SettingsPermissions({ - getIdentity: this.context.security.getIdentity, - getPermissions: () => - this.context.security.getPermissions("fm.settings"), - fullAccessPermissionName: "fm.*" - }); - - return createFileManager({ - storageOperations, - filesPermissions, - settingsPermissions, - getTenantId: this.getTenantId.bind(this), - getIdentity: this.getIdentity.bind(this), - getPermissions: this.getPermissions.bind(this), - storage: new FileStorage({ - context: this.context - }), - // TODO: maybe this is no longer necessary, as this wil be managed by CMS? - WEBINY_VERSION: this.context.WEBINY_VERSION - }); - } - - private getIdentity() { - return this.context.security.getIdentity(); - } - - private getTenantId() { - return this.context.tenancy.getCurrentTenant().id; - } - - private async getPermissions( - name: string - ): Promise { - return this.context.security.getPermissions(name); - } - - private async setupCmsStorageOperations(aliases: FileAliasesStorageOperations) { - if (!(await isHeadlessCmsReady(this.context))) { - return; - } - - const withPrivateFiles = this.context.wcp.canUsePrivateFiles(); - - // TODO: model modifier need to be implemented differently, via CMS model builder - // const modelModifiers = this.context.plugins.byType( - // CmsModelModifierPlugin.type - // ); - // - // for (const modifier of modelModifiers) { - // await modifier.modifyModel(fileModelDefinition); - // } - - // Finally, register all plugins - const fileModelDefinition = createFileModel({ withPrivateFiles }); - this.context.plugins.register(fileModelDefinition); - - // Now load the file model registered in the previous step - const fileModel = await this.getModel(FILE_MODEL_ID); - - // Overwrite the original `files` storage ops - return await CmsFilesStorage.create({ - fileModel, - cms: this.context.cms, - aliases - }); - } - - private async getModel(modelId: string) { - const model = await this.context.cms.getModel(modelId); - if (!model) { - throw new WebinyError({ - code: "MODEL_NOT_FOUND", - message: `Content model "${modelId}" was not found!` - }); - } - - return model; - } -} diff --git a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts b/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts deleted file mode 100644 index 93acc41fae9..00000000000 --- a/packages/api-file-manager/src/cmsFileStorage/CmsFilesStorage.ts +++ /dev/null @@ -1,168 +0,0 @@ -// @ts-nocheck This is being removed -import omit from "lodash/omit.js"; -import type { CmsEntry, CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; -import type { File, FileAliasesStorageOperations } from "~/types.js"; -import { ListFilesWhereProcessor } from "~/cmsFileStorage/ListFilesWhereProcessor.js"; -import { ListTagsWhereProcessor } from "~/cmsFileStorage/ListTagsWhereProcessor.js"; -import { ROOT_FOLDER } from "~/contants.js"; - -interface ModelContext { - tenant: string; -} - -export class CmsFilesStorage implements FileStorageOperations { - private readonly cms: HeadlessCms; - private readonly model: CmsModel; - private readonly aliases: FileAliasesStorageOperations; - private readonly filesWhereProcessor: ListFilesWhereProcessor; - private readonly tagsWhereProcessor: ListTagsWhereProcessor; - - static async create(params: { - fileModel: CmsModel; - cms: HeadlessCms; - aliases: FileAliasesStorageOperations; - }) { - return new CmsFilesStorage(params.fileModel, params.cms, params.aliases); - } - - private constructor( - fileModel: CmsModel, - cms: HeadlessCms, - aliases: FileAliasesStorageOperations - ) { - this.model = fileModel; - this.aliases = aliases; - this.cms = cms; - this.filesWhereProcessor = new ListFilesWhereProcessor(); - this.tagsWhereProcessor = new ListTagsWhereProcessor(); - } - - private modelWithContext({ tenant }: ModelContext): CmsModel { - return { ...this.model, tenant }; - } - - async create({ file }: FileManagerFilesStorageOperationsCreateParams): Promise { - const model = this.modelWithContext(file); - - if (!file.location?.folderId) { - file.location = { - ...file.location, - folderId: ROOT_FOLDER - }; - } - - const entry = await this.cms.createEntry(model, { - ...file, - wbyAco_location: file.location - }); - - await this.aliases.storeAliases(file); - - return this.getFileFieldValues(entry); - } - - async createBatch({ - files - }: FileManagerFilesStorageOperationsCreateBatchParams): Promise { - return await Promise.all( - files.map(file => { - return this.create({ file }); - }) - ); - } - - async delete({ file }: FileManagerFilesStorageOperationsDeleteParams): Promise { - const model = this.modelWithContext(file); - await this.cms.deleteEntry(model, file.id); - - await this.aliases.deleteAliases(file); - } - - async get({ where }: FileManagerFilesStorageOperationsGetParams): Promise { - const { id, tenant } = where; - const model = this.modelWithContext({ tenant }); - const entry = await this.cms.getEntry(model, { where: { entryId: id, latest: true } }); - return entry ? this.getFileFieldValues(entry) : null; - } - - async list( - params: FileManagerFilesStorageOperationsListParams - ): Promise { - const tenant = params.where.tenant; - - const model = this.modelWithContext({ tenant }); - - const where = this.filesWhereProcessor.process(params.where); - const [entries, meta] = await this.cms.listLatestEntries(model, { - after: params.after, - limit: params.limit, - sort: params.sort, - where, - search: params.search - }); - - return [entries.map(entry => this.getFileFieldValues(entry)), meta]; - } - - async tags( - params: FileManagerFilesStorageOperationsTagsParams - ): Promise { - const tenant = params.where.tenant; - const model = this.modelWithContext({ tenant }); - const uniqueValues = await this.cms.getUniqueFieldValues(model, { - fieldId: "tags", - where: { - ...this.tagsWhereProcessor.process(params.where), - latest: true - } - }); - - return uniqueValues - .map(uv => ({ - tag: uv.value, - count: uv.count - })) - .sort((a, b) => { - return a.tag < b.tag ? -1 : 1; - }) - .sort((a, b) => { - return a.count > b.count ? -1 : 1; - }); - } - - async update({ file }: FileManagerFilesStorageOperationsUpdateParams): Promise { - const model = this.modelWithContext(file); - - const entry = await this.cms.getEntry(model, { - where: { entryId: file.id, latest: true } - }); - - const values = omit(file, ["id", "tenant", "webinyVersion"]); - - const updatedEntry = await this.cms.updateEntry(model, entry.id, { - ...values, - wbyAco_location: values.location ?? entry.location - }); - - await this.aliases.storeAliases(file); - - return this.getFileFieldValues(updatedEntry); - } - - private getFileFieldValues(entry: CmsEntry) { - return { - id: entry.entryId, - - // We're safe to use entry-level meta fields because we don't use revisions with files. - createdBy: entry.createdBy, - modifiedBy: entry.modifiedBy || null, - savedBy: entry.savedBy, - createdOn: entry.createdOn, - modifiedOn: entry.modifiedOn || null, - savedOn: entry.savedOn, - tenant: entry.tenant, - webinyVersion: entry.webinyVersion, - ...entry.values - } as File; - } -} diff --git a/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts b/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts deleted file mode 100644 index b98bd66ef14..00000000000 --- a/packages/api-file-manager/src/cmsFileStorage/ListFilesWhereProcessor.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CmsEntryListWhere } from "@webiny/api-headless-cms/types/index.js"; -import type { FileManagerFilesStorageOperationsListParamsWhere } from "~/types.js"; - -type StandardFileKey = keyof FileManagerFilesStorageOperationsListParamsWhere; -type CmsEntryListWhereKey = keyof CmsEntryListWhere; - -export class ListFilesWhereProcessor { - private readonly skipKeys = ["tenant"]; - private readonly keyMap: Partial> = { - id: "entryId", - id_in: "entryId_in" - }; - - process(input: FileManagerFilesStorageOperationsListParamsWhere): CmsEntryListWhere { - const where: CmsEntryListWhere = { meta: { private_not: true } }; - - Object.keys(input) - .filter(key => !this.skipKeys.includes(key)) - .forEach(key => { - const remappedKey = this.keyMap[key]; - const value = input[key]; - - if (remappedKey && value !== undefined) { - where[remappedKey] = value; - } else if (value !== undefined) { - where[key] = value; - } - }); - - return where; - } -} diff --git a/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts b/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts deleted file mode 100644 index c499e3f8c3b..00000000000 --- a/packages/api-file-manager/src/cmsFileStorage/ListTagsWhereProcessor.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CmsEntryListWhere } from "@webiny/api-headless-cms/types/index.js"; -import type { FileManagerFilesStorageOperationsListParamsWhere } from "~/types.js"; - -type StandardFileKey = keyof FileManagerFilesStorageOperationsListParamsWhere; -type CmsEntryListWhereKey = keyof CmsEntryListWhere; - -export class ListTagsWhereProcessor { - private readonly skipKeys = ["tenant"]; - private readonly keyMap: Partial> = { - tag_startsWith: "tags_startsWith", - tag_not_startsWith: "tags_not_startsWith" - }; - - process(input: FileManagerFilesStorageOperationsListParamsWhere): CmsEntryListWhere { - const where: CmsEntryListWhere = { meta: { private_not: true } }; - - Object.keys(input) - .filter(key => !this.skipKeys.includes(key)) - .forEach(key => { - const remappedKey = this.keyMap[key]; - const value = input[key]; - - if (remappedKey && value !== undefined) { - where[remappedKey] = value; - } else if (value !== undefined) { - where[key] = value; - } - }); - - return where; - } -} diff --git a/packages/api-file-manager/src/createFileManager/files.crud.ts b/packages/api-file-manager/src/createFileManager/files.crud.ts index b657e24a61a..84e83e1de9c 100644 --- a/packages/api-file-manager/src/createFileManager/files.crud.ts +++ b/packages/api-file-manager/src/createFileManager/files.crud.ts @@ -1,3 +1,4 @@ +// @ts-nocheck being removed import { NotFoundError } from "@webiny/handler-graphql"; import { createTopic } from "@webiny/pubsub"; import WebinyError from "@webiny/error"; @@ -8,7 +9,6 @@ import type { FilesCRUD, FilesListOpts } from "~/types.js"; -import type { FileManagerConfig } from "~/createFileManager/types.js"; import { ROOT_FOLDER } from "~/contants.js"; import { getDate } from "@webiny/api-headless-cms/utils/date.js"; import { getIdentity as utilsGetIdentity } from "@webiny/api-headless-cms/utils/identity.js"; diff --git a/packages/api-file-manager/src/createFileManager/index.ts b/packages/api-file-manager/src/createFileManager/index.ts deleted file mode 100644 index f4622ce8442..00000000000 --- a/packages/api-file-manager/src/createFileManager/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { FileManagerContextObject } from "~/types.js"; -import { createFilesCrud } from "~/createFileManager/files.crud.js"; -import { createSettingsCrud } from "~/createFileManager/settings.crud.js"; -import type { FileManagerConfig } from "~/createFileManager/types.js"; - -export const createFileManager = (config: FileManagerConfig): FileManagerContextObject => { - const filesCrud = createFilesCrud(config); - const settingsCrud = createSettingsCrud(config); - - return { - ...filesCrud, - ...settingsCrud, - storage: config.storage - }; -}; diff --git a/packages/api-file-manager/src/createFileManager/settings.crud.ts b/packages/api-file-manager/src/createFileManager/settings.crud.ts deleted file mode 100644 index fd8c9057de9..00000000000 --- a/packages/api-file-manager/src/createFileManager/settings.crud.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createTopic } from "@webiny/pubsub"; -import type { FileManagerSettings, SettingsCRUD } from "~/types.js"; -import type { FileManagerConfig } from "./types.js"; -import zod from "zod"; -import { createZodError } from "@webiny/utils"; - -const MIN_FILE_SIZE = 0; -const MAX_FILE_SIZE = 10737418240; - -const uploadMinFileSizeValidation = zod - .number() - .min(MIN_FILE_SIZE, { - message: `Value needs to be greater than or equal to ${MIN_FILE_SIZE}.` - }) - .optional(); -const uploadMaxFileSizeValidation = zod - .number() - .max(MAX_FILE_SIZE, { - message: `Value needs to be lesser than or equal to ${MAX_FILE_SIZE}.` - }) - .optional(); - -const createDataModelValidation = zod.object({ - uploadMinFileSize: uploadMinFileSizeValidation.default(MIN_FILE_SIZE), - uploadMaxFileSize: uploadMaxFileSizeValidation.default(MAX_FILE_SIZE), - srcPrefix: zod - .string() - .optional() - .default("/files/") - .transform(value => { - if (typeof value === "string") { - return value.endsWith("/") ? value : value + "/"; - } - return value; - }) -}); - -const updateDataModelValidation = zod.object({ - uploadMinFileSize: uploadMinFileSizeValidation, - uploadMaxFileSize: uploadMaxFileSizeValidation, - srcPrefix: zod - .string() - .optional() - .transform(value => { - if (typeof value === "string") { - return value.endsWith("/") ? value : value + "/"; - } - return value; - }) -}); - -export const createSettingsCrud = ({ - storageOperations, - getTenantId, - settingsPermissions -}: Pick< - FileManagerConfig, - "storageOperations" | "getTenantId" | "settingsPermissions" ->): SettingsCRUD => { - return { - onSettingsBeforeUpdate: createTopic("fileManager.onSettingsBeforeUpdate"), - onSettingsAfterUpdate: createTopic("fileManager.onSettingsAfterUpdate"), - async getSettings() { - return storageOperations.settings.get({ tenant: getTenantId() }); - }, - async createSettings(data) { - await settingsPermissions.ensure(); - - const results = createDataModelValidation.safeParse(data); - if (!results.success) { - throw createZodError(results.error); - } - - return storageOperations.settings.create({ - data: { - ...results.data, - tenant: getTenantId() - } - }); - }, - async updateSettings(data) { - await settingsPermissions.ensure(); - const results = updateDataModelValidation.safeParse(data); - if (!results.success) { - throw createZodError(results.error); - } - - const original = (await storageOperations.settings.get({ - tenant: getTenantId() - })) as FileManagerSettings; - const newSettings: FileManagerSettings = { - ...(original || {}) - }; - - for (const key in results.data) { - // @ts-expect-error - const value = results.data[key]; - if (value === undefined) { - continue; - } - // @ts-expect-error - newSettings[key] = value; - } - - const settings: FileManagerSettings = { - ...newSettings, - tenant: getTenantId() - }; - - await this.onSettingsBeforeUpdate.publish({ - input: data, - original, - settings - }); - const result = await storageOperations.settings.update({ - original, - data: settings - }); - await this.onSettingsAfterUpdate.publish({ - input: data, - original, - settings: result - }); - - return result; - }, - async deleteSettings() { - await settingsPermissions.ensure(); - await storageOperations.settings.delete({ tenant: getTenantId() }); - - return true; - } - }; -}; diff --git a/packages/api-file-manager/src/createFileManager/types.ts b/packages/api-file-manager/src/createFileManager/types.ts deleted file mode 100644 index 0dd139dd1d9..00000000000 --- a/packages/api-file-manager/src/createFileManager/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { FileManagerStorageOperations } from "~/types.js"; -import type { FilesPermissions } from "./permissions/FilesPermissions.js"; -import type { FileStorage } from "~/storage/FileStorage.js"; -import type { SettingsPermissions } from "./permissions/SettingsPermissions.js"; -import type { GetPermissions, SecurityIdentity } from "@webiny/api-core/types/security.js"; - -export interface FileManagerConfig { - storageOperations: FileManagerStorageOperations; - filesPermissions: FilesPermissions; - settingsPermissions: SettingsPermissions; - getTenantId: () => string; - getIdentity: () => SecurityIdentity; - getPermissions: GetPermissions; - storage: FileStorage; - WEBINY_VERSION: string; -} diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts b/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts index cdc6ad71c55..0c61cb3ab6b 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/AssetDeliveryConfig.ts @@ -10,12 +10,12 @@ import type { ResponseHeadersSetter } from "~/delivery/index.js"; import { SetResponseHeaders } from "~/delivery/index.js"; -import type { FileManagerContext } from "~/types.js"; import { NullRequestResolver } from "~/delivery/AssetDelivery/NullRequestResolver.js"; import { NullAssetResolver } from "~/delivery/AssetDelivery/NullAssetResolver.js"; import { NullAssetOutputStrategy } from "./NullAssetOutputStrategy.js"; import { TransformationAssetProcessor } from "./transformation/TransformationAssetProcessor.js"; import { PassthroughAssetTransformationStrategy } from "./transformation/PassthroughAssetTransformationStrategy.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; type Setter = (params: TParams) => TReturn; @@ -27,17 +27,17 @@ export type AssetRequestResolverDecorator = Setter< export type AssetResolverDecorator = Setter<{ assetResolver: AssetResolver }, AssetResolver>; export type AssetProcessorDecorator = Setter< - { context: FileManagerContext; assetProcessor: AssetProcessor }, + { context: ApiCoreContext; assetProcessor: AssetProcessor }, AssetProcessor >; export type AssetTransformationDecorator = Setter< - { context: FileManagerContext; assetTransformationStrategy: AssetTransformationStrategy }, + { context: ApiCoreContext; assetTransformationStrategy: AssetTransformationStrategy }, AssetTransformationStrategy >; export interface AssetOutputStrategyDecoratorParams { - context: FileManagerContext; + context: ApiCoreContext; assetRequest: AssetRequest; asset: Asset; assetOutputStrategy: AssetOutputStrategy; @@ -104,14 +104,14 @@ export class AssetDeliveryConfigBuilder { /** * @internal */ - getAssetProcessor(context: FileManagerContext) { + getAssetProcessor(context: ApiCoreContext) { return this.assetProcessorDecorators.reduce( (value, decorator) => decorator({ assetProcessor: value, context }), new TransformationAssetProcessor(this.getAssetTransformationStrategy(context)) ); } - getAssetOutputStrategy(context: FileManagerContext, assetRequest: AssetRequest, asset: Asset) { + getAssetOutputStrategy(context: ApiCoreContext, assetRequest: AssetRequest, asset: Asset) { return this.assetOutputStrategyDecorators.reduce( (value, decorator) => { return decorator({ context, assetRequest, asset, assetOutputStrategy: value }); @@ -120,7 +120,7 @@ export class AssetDeliveryConfigBuilder { ); } - getAssetTransformationStrategy(context: FileManagerContext) { + getAssetTransformationStrategy(context: ApiCoreContext) { return this.assetTransformationStrategyDecorators.reduce( (value, decorator) => decorator({ context, assetTransformationStrategy: value }), new PassthroughAssetTransformationStrategy() diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts b/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts index f824bfc73ee..0e3c5c6bbc4 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/SetResponseHeaders.ts @@ -5,12 +5,12 @@ import type { AssetRequest, AssetOutputStrategyDecoratorParams } from "~/delivery/index.js"; -import type { FileManagerContext } from "~/types.js"; import type { ResponseHeaders } from "@webiny/handler"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export interface ResponseHeadersParams { headers: ResponseHeaders; - context: FileManagerContext; + context: ApiCoreContext; assetRequest: AssetRequest; asset: Asset; } diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts index cf846d63c49..96f48b21619 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/AssetAuthorizer.ts @@ -1,4 +1,4 @@ -import type { File } from "~/types.js"; +import { File } from "~/domain/file/types.js"; export interface AssetAuthorizer { authorize(file: File): Promise; diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts index 32f0e8d2851..d849043ebe0 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.ts @@ -1,11 +1,12 @@ import Error from "@webiny/error"; -import type { File, FileManagerContext } from "~/types.js"; +import type { File } from "~/domain/file/types.js"; import type { AssetAuthorizer } from "./AssetAuthorizer.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export class PrivateAuthenticatedAuthorizer implements AssetAuthorizer { - private context: FileManagerContext; + private context: ApiCoreContext; - constructor(context: FileManagerContext) { + constructor(context: ApiCoreContext) { this.context = context; } diff --git a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts index 2a67bbec9fc..14bd351d65e 100644 --- a/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts +++ b/packages/api-file-manager/src/delivery/AssetDelivery/privateFiles/PrivateFilesAssetProcessor.ts @@ -1,4 +1,5 @@ -import type { File, FileManagerContext } from "~/types.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import type { File } from "~/domain/file/types.js"; import type { Asset, AssetProcessor, AssetRequest } from "~/delivery/index.js"; import type { AssetAuthorizer } from "./AssetAuthorizer.js"; import { NotAuthorizedOutputStrategy } from "./NotAuthorizedOutputStrategy.js"; @@ -6,18 +7,19 @@ import { RedirectToPublicUrlOutputStrategy } from "./RedirectToPublicUrlOutputSt import { RedirectToPrivateUrlOutputStrategy } from "./RedirectToPrivateUrlOutputStrategy.js"; import { PrivateCache } from "./PrivateCache.js"; import { PublicCache } from "./PublicCache.js"; +import { GetFileUseCase } from "~/features/file/GetFile/index.js"; interface MaybePrivate { private?: boolean; } export class PrivateFilesAssetProcessor implements AssetProcessor { - private readonly context: FileManagerContext; + private readonly context: ApiCoreContext; private assetProcessor: AssetProcessor; private assetAuthorizer: AssetAuthorizer; constructor( - context: FileManagerContext, + context: ApiCoreContext, assetAuthorizer: AssetAuthorizer, assetProcessor: AssetProcessor ) { @@ -28,10 +30,17 @@ export class PrivateFilesAssetProcessor implements AssetProcessor { async process(assetRequest: AssetRequest, asset: Asset): Promise { const id = asset.getId(); - const { security, fileManager } = this.context; + const { security } = this.context; + const getFile = this.context.container.resolve(GetFileUseCase); // Get file from File Manager by `id`. - const file = await security.withoutAuthorization(() => fileManager.getFile(id)); + const file = await security.withoutAuthorization(async () => { + const fileResult = await getFile.execute(id); + if (fileResult.isFail()) { + throw fileResult.error; + } + return fileResult.value; + }); const isPrivateFile = this.isPrivate(file); diff --git a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts index e61c225f4b6..fa263e51e62 100644 --- a/packages/api-file-manager/src/delivery/setupAssetDelivery.ts +++ b/packages/api-file-manager/src/delivery/setupAssetDelivery.ts @@ -5,7 +5,6 @@ import { createRoute, ResponseHeaders } from "@webiny/handler"; -import type { FileManagerContext } from "~/types.js"; import { PrivateFilesAssetProcessor } from "./AssetDelivery/privateFiles/PrivateFilesAssetProcessor.js"; import { PrivateAuthenticatedAuthorizer } from "./AssetDelivery/privateFiles/PrivateAuthenticatedAuthorizer.js"; import { PrivateFileAssetRequestResolver } from "./AssetDelivery/privateFiles/PrivateFileAssetRequestResolver.js"; @@ -18,6 +17,7 @@ import { createAssetDeliveryConfig } from "./index.js"; import type { Reply } from "@webiny/handler/types.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; const noCacheHeaders = ResponseHeaders.create({ "content-type": "application/json", @@ -106,7 +106,7 @@ export const setupAssetDelivery = (params: AssetDeliveryParams) => { }); // Create the `Route` plugin, to handle all GET requests, and output the resolved asset. - const deliveryRoute = createRoute(({ onGet, context }) => { + const deliveryRoute = createRoute(({ onGet, context }) => { onGet( "*", async (_, reply) => { diff --git a/packages/api-file-manager/src/domain/file/types.ts b/packages/api-file-manager/src/domain/file/types.ts index 52aa22e0cc8..cf0e97037e8 100644 --- a/packages/api-file-manager/src/domain/file/types.ts +++ b/packages/api-file-manager/src/domain/file/types.ts @@ -10,7 +10,7 @@ export type FileAccess = PublicAccess | PrivateAuthenticatedAccess; export interface CreatedBy { id: string; - displayName: string | null; + displayName: string; type: string; } @@ -28,10 +28,10 @@ export interface File { tags: string[]; aliases: string[]; createdOn: string; - modifiedOn: string | null; + modifiedOn: string | undefined; savedOn: string; createdBy: CreatedBy; - modifiedBy: CreatedBy | null; + modifiedBy: CreatedBy | undefined; savedBy: CreatedBy; extensions?: Record; } @@ -46,9 +46,9 @@ export interface FileInput { id: string; // Entry-level fields (we don't use revisions for files) - createdOn?: string | Date; - modifiedOn?: string | Date; - savedOn?: string | Date; + createdOn?: string; + modifiedOn?: string; + savedOn?: string; createdBy?: CreatedBy; modifiedBy?: CreatedBy; savedBy?: CreatedBy; diff --git a/packages/api-file-manager/src/domain/identity/Identity.ts b/packages/api-file-manager/src/domain/identity/Identity.ts new file mode 100644 index 00000000000..ab9413686bb --- /dev/null +++ b/packages/api-file-manager/src/domain/identity/Identity.ts @@ -0,0 +1,15 @@ +interface IdentityDto { + id: string; + displayName: string; + type: string; +} + +export class Identity { + static from(identity: IdentityDto): IdentityDto { + return { + id: identity.id, + displayName: identity.displayName, + type: identity.type + }; + } +} diff --git a/packages/api-file-manager/src/domain/settings/errors.ts b/packages/api-file-manager/src/domain/settings/errors.ts index 0042228fe68..565a23529d8 100644 --- a/packages/api-file-manager/src/domain/settings/errors.ts +++ b/packages/api-file-manager/src/domain/settings/errors.ts @@ -1,4 +1,5 @@ import { BaseError } from "@webiny/feature/api"; +import type { OutputErrors } from "@webiny/utils/createZodError.js"; export class SettingsNotFoundError extends BaseError { override readonly code = "FileManager/Settings/NotFoundError" as const; @@ -19,3 +20,18 @@ export class SettingsUpdateError extends BaseError { }); } } + +interface ValidationParams { + invalidFields: OutputErrors; +} + +export class SettingsValidationError extends BaseError { + override readonly code = "FileManager/Settings/ValidationError" as const; + + constructor(invalidFields: OutputErrors) { + super({ + message: "Validation failed.", + data: { invalidFields } + }); + } +} diff --git a/packages/api-file-manager/src/domain/settings/validation.ts b/packages/api-file-manager/src/domain/settings/validation.ts new file mode 100644 index 00000000000..25336d6b408 --- /dev/null +++ b/packages/api-file-manager/src/domain/settings/validation.ts @@ -0,0 +1,32 @@ +import zod from "zod"; + +const MIN_FILE_SIZE = 0; +const MAX_FILE_SIZE = 10737418240; + +const uploadMinFileSizeValidation = zod + .number() + .min(MIN_FILE_SIZE, { + message: `Value needs to be greater than or equal to ${MIN_FILE_SIZE}.` + }) + .optional(); + +const uploadMaxFileSizeValidation = zod + .number() + .max(MAX_FILE_SIZE, { + message: `Value needs to be lesser than or equal to ${MAX_FILE_SIZE}.` + }) + .optional(); + +export const updateSettingsValidation = zod.object({ + uploadMinFileSize: uploadMinFileSizeValidation, + uploadMaxFileSize: uploadMaxFileSizeValidation, + srcPrefix: zod + .string() + .optional() + .transform(value => { + if (typeof value === "string") { + return value.endsWith("/") ? value : value + "/"; + } + return value; + }) +}); diff --git a/packages/api-file-manager/src/enterprise/applyThreatScanning.ts b/packages/api-file-manager/src/enterprise/applyThreatScanning.ts deleted file mode 100644 index f07827d88d8..00000000000 --- a/packages/api-file-manager/src/enterprise/applyThreatScanning.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { FileManagerContext } from "~/types.js"; -import { decorateContext } from "@webiny/api"; - -// TODO: implement this via a use case decorator -export const applyThreatScanning = (context: FileManagerContext["fileManager"]) => { - return decorateContext(context, { - createFile: decoratee => (data, meta) => { - return decoratee( - { - ...data, - tags: [...data.tags, "threatScanInProgress"] - }, - meta - ); - } - }); -}; diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts index a16c7f50da7..d341ee86c35 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileUseCase.ts @@ -10,9 +10,12 @@ import type { File, FileInput } from "~/domain/file/types.js"; import { FileNotAuthorizedError, InvalidFileSizeError } from "~/domain/file/errors.js"; import { FileBeforeCreateEvent, FileAfterCreateEvent } from "./events.js"; import { FilePermissions } from "~/features/shared/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { Identity } from "~/domain/identity/Identity.js"; class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { constructor( + private identityContext: IdentityContext.Interface, private filePermissions: FilePermissions.Interface, private repository: CreateFileRepository.Interface, private getSettings: GetSettingsUseCase.Interface, @@ -34,6 +37,7 @@ class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { } const [id] = input.key.split("/"); + const currentIdentity = this.identityContext.getIdentity(); // Prepare file input const fileInput: FileInput = { @@ -46,7 +50,16 @@ class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { location: input.location || { folderId: "root" }, tags: input.tags || [], aliases: input.aliases || [], - extensions: meta || {} + extensions: meta || {}, + // system attributes + createdOn: input.createdOn, + modifiedOn: input.modifiedOn, + savedOn: input.savedOn, + createdBy: input.createdBy + ? Identity.from(input.createdBy) + : Identity.from(currentIdentity), + modifiedBy: input.modifiedBy ? Identity.from(input.modifiedBy) : undefined, + savedBy: input.savedBy ? Identity.from(input.savedBy) : Identity.from(currentIdentity) }; await this.eventPublisher.publish(new FileBeforeCreateEvent({ file: fileInput, meta })); @@ -95,5 +108,11 @@ class CreateFileUseCaseImpl implements UseCaseAbstraction.Interface { export const CreateFileUseCase = UseCaseAbstraction.createImplementation({ implementation: CreateFileUseCaseImpl, - dependencies: [FilePermissions, CreateFileRepository, GetSettingsUseCase, EventPublisher] + dependencies: [ + IdentityContext, + FilePermissions, + CreateFileRepository, + GetSettingsUseCase, + EventPublisher + ] }); diff --git a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts index ae03a187e02..43859057502 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts @@ -1,6 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; -import type { File, FileInput } from "~/domain/file/types.js"; +import type { CreatedBy, File, FileInput } from "~/domain/file/types.js"; import { type FilePersistenceError, type InvalidFileSizeError, @@ -18,6 +18,13 @@ export interface CreateFileInput { tags?: string[]; location?: { folderId: string }; aliases?: string[]; + // System attributes + createdOn?: string; + createdBy?: CreatedBy; + modifiedOn?: string; + modifiedBy?: CreatedBy; + savedOn?: string; + savedBy?: CreatedBy; } /** diff --git a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts index 5086b0be39e..d9132e23e13 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts @@ -18,7 +18,7 @@ class DeleteFileUseCaseImpl implements UseCaseAbstraction.Interface { private eventPublisher: EventPublisher.Interface ) {} - async execute(input: DeleteFileInput): Promise> { + async execute(id: string): Promise> { // Check delete permission const hasPermission = await this.filePermissions.ensure({ rwd: "d" }); if (!hasPermission) { @@ -26,7 +26,7 @@ class DeleteFileUseCaseImpl implements UseCaseAbstraction.Interface { } // Get file (includes ownership check) - const getResult = await this.getFile.execute({ id: input.id }); + const getResult = await this.getFile.execute(id); if (getResult.isFail()) { return Result.fail(getResult.error); } diff --git a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts index 45e9561cb99..e26b5af01fb 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts @@ -36,7 +36,7 @@ export namespace DeleteFileRepository { * DeleteFile use case interface */ export interface IDeleteFileUseCase { - execute(input: DeleteFileInput): Promise>; + execute(id: string): Promise>; } export interface IDeleteFileUseCaseErrors { diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts index 09c0c53c236..119a48832d3 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts @@ -10,9 +10,12 @@ import type { File } from "~/domain/file/types.js"; import { FileNotAuthorizedError } from "~/domain/file/errors.js"; import { FileBeforeUpdateEvent, FileAfterUpdateEvent } from "./events.js"; import { FilePermissions } from "~/features/shared/abstractions.js"; +import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; +import { Identity } from "~/domain/identity/Identity.js"; class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { constructor( + private identityContext: IdentityContext.Interface, private filePermissions: FilePermissions.Interface, private getFile: GetFileUseCase.Interface, private repository: UpdateFileRepository.Interface, @@ -26,15 +29,24 @@ class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { return Result.fail(new FileNotAuthorizedError()); } - // Get original file (includes ownership check) - const getResult = await this.getFile.execute(input.id); + // Get the original file (includes ownership check) + const getResult = await this.identityContext.withoutAuthorization(() => { + return this.getFile.execute(input.id); + }); + if (getResult.isFail()) { return Result.fail(getResult.error); } const original = getResult.value; - // Build updated file for event + const isOwn = await this.filePermissions.ensure({ owns: original.createdBy }); + if (!isOwn) { + return Result.fail(new FileNotAuthorizedError()); + } + + const currentIdentity = this.identityContext.getIdentity(); + const file: File = { ...original, ...input, @@ -43,12 +55,19 @@ class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { key: original.key, size: original.size, type: original.type, - createdOn: original.createdOn, - createdBy: original.createdBy, // Update mutable fields tags: input.tags !== undefined ? input.tags : original.tags, aliases: input.aliases !== undefined ? input.aliases : original.aliases, - location: input.location !== undefined ? input.location : original.location + location: input.location !== undefined ? input.location : original.location, + // System fields + createdOn: input.createdOn ?? original.createdOn, + modifiedOn: input.modifiedOn ?? undefined, + savedOn: input.savedOn ?? original.savedOn, + createdBy: input.createdBy ? Identity.from(input.createdBy) : original.createdBy, + modifiedBy: input.modifiedBy + ? Identity.from(input.modifiedBy) + : Identity.from(currentIdentity), + savedBy: input.savedBy ? Identity.from(input.savedBy) : Identity.from(currentIdentity) }; await this.eventPublisher.publish(new FileBeforeUpdateEvent({ original, file, input })); @@ -67,5 +86,11 @@ class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { export const UpdateFileUseCase = UseCaseAbstraction.createImplementation({ implementation: UpdateFileUseCaseImpl, - dependencies: [FilePermissions, GetFileUseCase, UpdateFileRepository, EventPublisher] + dependencies: [ + IdentityContext, + FilePermissions, + GetFileUseCase, + UpdateFileRepository, + EventPublisher + ] }); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts index ac42613783c..1f36f3c2bbb 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts @@ -1,6 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; -import type { File } from "~/domain/file/types.js"; +import type { CreatedBy, File } from "~/domain/file/types.js"; import { type FilePersistenceError, type FileNotFoundError, @@ -14,6 +14,12 @@ export interface UpdateFileInput { tags?: string[]; location?: { folderId: string }; aliases?: string[]; + createdOn?: string; + modifiedOn?: string; + savedOn?: string; + createdBy?: CreatedBy; + modifiedBy?: CreatedBy; + savedBy?: CreatedBy; } /** diff --git a/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts index e75593c9c0e..f438fcef95e 100644 --- a/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts +++ b/packages/api-file-manager/src/features/file/shared/EntryToFileMapper.ts @@ -6,10 +6,10 @@ export class EntryToFileMapper { return { id: entry.entryId, createdOn: entry.createdOn, - modifiedOn: entry.modifiedOn, + modifiedOn: entry.modifiedOn ?? undefined, savedOn: entry.savedOn, createdBy: entry.createdBy, - modifiedBy: entry.modifiedBy, + modifiedBy: entry.modifiedBy ?? undefined, savedBy: entry.savedBy, name: entry.values.name, key: entry.values.key, diff --git a/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts index 92c46b38264..6d0b08d8089 100644 --- a/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/GetSettings/GetSettingsUseCase.ts @@ -2,7 +2,6 @@ import { Result } from "@webiny/feature/api"; import { GetSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { GetSettings } from "@webiny/api-core/features/settings/GetSettings"; import type { FileManagerSettings } from "~/domain/settings/types.js"; -import { SettingsNotFoundError } from "~/domain/settings/errors.js"; import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; class GetSettingsUseCaseImpl implements UseCaseAbstraction.Interface { @@ -12,6 +11,7 @@ class GetSettingsUseCaseImpl implements UseCaseAbstraction.Interface { const result = await this.getSettings.execute(FILE_MANAGER_GENERAL_SETTINGS); if (result.isFail()) { + // Return default values return Result.ok({ uploadMinFileSize: 0, uploadMaxFileSize: 10737418240, diff --git a/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts index 274a7e9f69e..0fce687294d 100644 --- a/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts +++ b/packages/api-file-manager/src/features/settings/GetSettings/abstractions.ts @@ -1,11 +1,8 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; import type { FileManagerSettings } from "~/domain/settings/types.js"; -import { SettingsNotFoundError } from "~/domain/settings/errors.js"; -export interface IGetSettingsUseCaseErrors { - notFound: SettingsNotFoundError; -} +export interface IGetSettingsUseCaseErrors {} type UseCaseError = IGetSettingsUseCaseErrors[keyof IGetSettingsUseCaseErrors]; diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts deleted file mode 100644 index d789169c38e..00000000000 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsEventsDecorator.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { Result } from "@webiny/feature/api"; -import { - UpdateSettingsUseCase as UseCaseAbstraction -} from "./abstractions.js"; -import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; -import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; -import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; - -/** - * Event types for settings lifecycle - */ -export interface SettingsBeforeUpdateEvent { - original: FileManagerSettings | null; - settings: FileManagerSettings; - input: UpdateSettingsInput; -} - -export interface SettingsAfterUpdateEvent { - original: FileManagerSettings | null; - settings: FileManagerSettings; - input: UpdateSettingsInput; -} - -export interface SettingsUpdateErrorEvent { - input: UpdateSettingsInput; - error: Error; -} - -class UpdateSettingsEventsDecoratorImpl implements UseCaseAbstraction.Interface { - constructor( - private useCase: UseCaseAbstraction.Interface, - private eventPublisher: EventPublisher.Interface, - private getSettings: GetSettingsUseCase.Interface - ) {} - - async execute(input: UpdateSettingsInput): Promise> { - // Get original settings - const originalResult = await this.getSettings.execute(); - const original = originalResult.value; - - try { - // Execute use case - const result = await this.useCase.execute(input); - - if (result.isFail()) { - // Publish error event - await this.eventPublisher.publish({ - type: "fileManager.settings.update.error", - data: { - input, - error: result.error - } - }); - return result; - } - - const settings = result.value; - - // Publish before event - await this.eventPublisher.publish({ - type: "fileManager.settings.update.before", - data: { - original, - settings, - input - } - }); - - // Publish after event - await this.eventPublisher.publish({ - type: "fileManager.settings.update.after", - data: { - original, - settings, - input - } - }); - - return result; - } catch (error) { - // Publish error event for exceptions - await this.eventPublisher.publish({ - type: "fileManager.settings.update.error", - data: { - input, - error: error as Error - } - }); - throw error; - } - } -} - -export const UpdateSettingsEventsDecorator = UseCaseAbstraction.createImplementation({ - implementation: UpdateSettingsEventsDecoratorImpl, - dependencies: [UseCaseAbstraction, EventPublisher, GetSettingsUseCase] -}); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index 5b33d08a66a..c8c73469d79 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -2,40 +2,79 @@ import { Result } from "@webiny/feature/api"; import { UpdateSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { UpdateSettings } from "@webiny/api-core/features/settings/UpdateSettings"; import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; -import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; +import type { FileManagerSettings } from "~/domain/settings/types.js"; +import type { UpdateSettingsInput } from "~/domain/settings/types.js"; import { SettingsUpdateError } from "~/domain/settings/errors.js"; +import { SettingsValidationError } from "~/domain/settings/errors.js"; import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; +import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; +import { SettingsBeforeUpdateEvent, SettingsAfterUpdateEvent } from "./events.js"; +import { updateSettingsValidation } from "~/domain/settings/validation.js"; +import { createZodError } from "@webiny/utils"; class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { constructor( private updateSettings: UpdateSettings.Interface, - private getSettings: GetSettingsUseCase.Interface + private getSettings: GetSettingsUseCase.Interface, + private eventPublisher: EventPublisher.Interface ) {} async execute( input: UpdateSettingsInput ): Promise> { + // Validate input + const validationResult = updateSettingsValidation.safeParse(input); + if (!validationResult.success) { + const zodError = createZodError(validationResult.error); + return Result.fail(new SettingsValidationError(zodError.data!.invalidFields)); + } + + const validatedInput = validationResult.data; + // Get existing settings to merge with new data const existingResult = await this.getSettings.execute(); const existing = existingResult.value; + // Prepare merged settings + const mergedSettings: FileManagerSettings = { + ...existing, + ...validatedInput + }; + + // Publish BeforeUpdate event + await this.eventPublisher.publish( + new SettingsBeforeUpdateEvent({ + original: existing, + settings: mergedSettings, + input: validatedInput + }) + ); + const result = await this.updateSettings.execute({ name: FILE_MANAGER_GENERAL_SETTINGS, - data: { - ...existing, - ...input - } + data: mergedSettings }); if (result.isFail()) { return Result.fail(new SettingsUpdateError(result.error)); } - return Result.ok(result.value.data as FileManagerSettings); + const updatedSettings = result.value.data as FileManagerSettings; + + // Publish AfterUpdate event + await this.eventPublisher.publish( + new SettingsAfterUpdateEvent({ + original: existing, + settings: updatedSettings, + input: validatedInput + }) + ); + + return Result.ok(updatedSettings); } } export const UpdateSettingsUseCase = UseCaseAbstraction.createImplementation({ implementation: UpdateSettingsUseCaseImpl, - dependencies: [UpdateSettings, GetSettingsUseCase] + dependencies: [UpdateSettings, GetSettingsUseCase, EventPublisher] }); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts index 81a5f353d42..a37238d6cad 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts @@ -1,7 +1,9 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; -import type { FileManagerSettings, UpdateSettingsInput } from "~/domain/settings/types.js"; +import type { FileManagerSettings } from "~/domain/settings/types.js"; +import type { UpdateSettingsInput } from "~/domain/settings/types.js"; import type { SettingsUpdateError } from "~/domain/settings/errors.js"; +import type { SettingsValidationError } from "~/domain/settings/errors.js"; /** * UpdateSettings use case interface @@ -12,6 +14,7 @@ export interface IUpdateSettingsUseCase { export interface IUpdateSettingsUseCaseErrors { updateError: SettingsUpdateError; + validationError: SettingsValidationError; } type UseCaseError = IUpdateSettingsUseCaseErrors[keyof IUpdateSettingsUseCaseErrors]; diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts new file mode 100644 index 00000000000..c3263a38bed --- /dev/null +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts @@ -0,0 +1,57 @@ +import { createAbstraction } from "@webiny/feature/api"; +import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; +import type { IEventHandler } from "@webiny/api-core/features/EventPublisher"; +import type { FileManagerSettings } from "~/domain/settings/types.js"; +import type { UpdateSettingsInput } from "~/domain/settings/types.js"; + +// ============================================================================ +// SettingsBeforeUpdate Event +// ============================================================================ + +export interface SettingsBeforeUpdatePayload { + original: FileManagerSettings; + settings: FileManagerSettings; + input: UpdateSettingsInput; +} + +export class SettingsBeforeUpdateEvent extends DomainEvent { + eventType = "FileManager/Settings/BeforeUpdate" as const; + + getHandlerAbstraction() { + return SettingsBeforeUpdateHandler; + } +} + +export const SettingsBeforeUpdateHandler = + createAbstraction>("SettingsBeforeUpdateHandler"); + +export namespace SettingsBeforeUpdateHandler { + export type Interface = IEventHandler; + export type Event = SettingsBeforeUpdateEvent; +} + +// ============================================================================ +// SettingsAfterUpdate Event +// ============================================================================ + +export interface SettingsAfterUpdatePayload { + original: FileManagerSettings; + settings: FileManagerSettings; + input: UpdateSettingsInput; +} + +export class SettingsAfterUpdateEvent extends DomainEvent { + eventType = "FileManager/Settings/AfterUpdate" as const; + + getHandlerAbstraction() { + return SettingsAfterUpdateHandler; + } +} + +export const SettingsAfterUpdateHandler = + createAbstraction>("SettingsAfterUpdateHandler"); + +export namespace SettingsAfterUpdateHandler { + export type Interface = IEventHandler; + export type Event = SettingsAfterUpdateEvent; +} diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts index a29331e7b6f..22e270e87ec 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/feature.ts @@ -1,12 +1,9 @@ import { createFeature } from "@webiny/feature/api"; import { UpdateSettingsUseCase } from "./UpdateSettingsUseCase.js"; -import { UpdateSettingsEventsDecorator } from "./UpdateSettingsEventsDecorator.js"; export const UpdateSettingsFeature = createFeature({ name: "FileManager/UpdateSettings", register(container) { container.register(UpdateSettingsUseCase); - // Register the decorator which wraps the use case with events - container.register(UpdateSettingsEventsDecorator); } }); diff --git a/packages/api-file-manager/src/graphql/baseSchema.ts b/packages/api-file-manager/src/graphql/baseSchema.ts index 59b203b0bec..4b244555ddf 100644 --- a/packages/api-file-manager/src/graphql/baseSchema.ts +++ b/packages/api-file-manager/src/graphql/baseSchema.ts @@ -1,15 +1,11 @@ -import { - ErrorResponse, - GraphQLSchemaPlugin, - Response -} from "@webiny/handler-graphql"; -import type { FileManagerContext } from "~/types.js"; +import { ErrorResponse, GraphQLSchemaPlugin, Response } from "@webiny/handler-graphql"; import { emptyResolver } from "./utils.js"; import { GetSettingsUseCase } from "~/features/settings/GetSettings/abstractions.js"; import { UpdateSettingsUseCase } from "~/features/settings/UpdateSettings/abstractions.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export const createBaseSchema = () => { - const fileManagerGraphQL = new GraphQLSchemaPlugin({ + const fileManagerGraphQL = new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` type FmError { code: String diff --git a/packages/api-file-manager/src/graphql/filesSchema.ts b/packages/api-file-manager/src/graphql/filesSchema.ts index a7bb24fe739..e6b6acfe2cd 100644 --- a/packages/api-file-manager/src/graphql/filesSchema.ts +++ b/packages/api-file-manager/src/graphql/filesSchema.ts @@ -4,7 +4,6 @@ import { ListResponse, Response } from "@webiny/handler-graphql"; -import type { FileManagerContext, FilesListOpts } from "~/types.js"; import { emptyResolver, resolve } from "./utils.js"; import type { CreateFilesTypeDefsParams } from "~/graphql/createFilesTypeDefs.js"; import { createFilesTypeDefs } from "~/graphql/createFilesTypeDefs.js"; @@ -17,9 +16,11 @@ import { CreateFilesInBatchUseCase } from "~/features/file/CreateFilesInBatch/ab import { UpdateFileUseCase } from "~/features/file/UpdateFile/abstractions.js"; import { DeleteFileUseCase } from "~/features/file/DeleteFile/abstractions.js"; import { GetSettingsUseCase } from "~/features/settings/GetSettings/abstractions.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { FileModel } from "~/domain/file/abstractions.js"; export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { - const fileManagerGraphQL = new GraphQLSchemaPlugin({ + const fileManagerGraphQL = new GraphQLSchemaPlugin({ typeDefs: createFilesTypeDefs(params), resolvers: { Query: { @@ -44,7 +45,9 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { return new NotAuthorizedResponse(); } - return resolve(() => context.cms.getModel("fmFile")); + return resolve(async () => { + return context.container.resolve(FileModel); + }); }, async getFile(_, args: any, context) { const getFile = context.container.resolve(GetFileUseCase); @@ -56,7 +59,7 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { return new Response(result.value); }, - async listFiles(_, args: FilesListOpts, context) { + async listFiles(_, args, context) { const listFiles = context.container.resolve(ListFilesUseCase); const result = await listFiles.execute(args); @@ -116,7 +119,7 @@ export const createFilesSchema = (params: CreateFilesTypeDefsParams) => { }, async deleteFile(_, args: any, context) { const deleteFile = context.container.resolve(DeleteFileUseCase); - const result = await deleteFile.execute({ id: args.id }); + const result = await deleteFile.execute(args.id); if (result.isFail()) { return new ErrorResponse(result.error); diff --git a/packages/api-file-manager/src/graphql/getFileByUrl.ts b/packages/api-file-manager/src/graphql/getFileByUrl.ts index 0531b56bc81..d7254915f0d 100644 --- a/packages/api-file-manager/src/graphql/getFileByUrl.ts +++ b/packages/api-file-manager/src/graphql/getFileByUrl.ts @@ -1,11 +1,13 @@ import { ErrorResponse, GraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { Response, NotFoundResponse } from "@webiny/handler-graphql"; -import type { FileManagerContext, FileManagerContextObject, File } from "~/types.js"; +import type { File } from "~/domain/file/types.js"; import type { Security } from "@webiny/api-core/types/security.js"; import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { ListFilesUseCase } from "~/features/file/ListFiles/index.js"; export const getFileByUrl = () => { - const fileManagerGraphQL = new GraphQLSchemaPlugin({ + const fileManagerGraphQL = new GraphQLSchemaPlugin({ typeDefs: /* GraphQL */ ` extend type FmQuery { getFileByUrl(url: String!): FmFileResponse @@ -17,7 +19,7 @@ export const getFileByUrl = () => { const { url } = args as { url: string }; const useCase = new SecureGetFileByUrl( context.security, - new GetFileByUrlUseCase(context.fileManager) + new GetFileByUrlUseCase(context.container.resolve(ListFilesUseCase)) ); try { const file = await useCase.execute(url); @@ -42,24 +44,22 @@ interface IGetFileByUrl { } class GetFileByUrlUseCase implements IGetFileByUrl { - private readonly fileManager: FileManagerContextObject; - - constructor(fileManager: FileManagerContextObject) { - this.fileManager = fileManager; - } + constructor(private listFiles: ListFilesUseCase.Interface) {} async execute(url: string): Promise { const { pathname } = new URL(url); const isAlias = !pathname.startsWith("/files/") && !pathname.startsWith("/private/"); const query = isAlias ? pathname : pathname.replace("/files/", "").replace("/private/", ""); - const [files] = await this.fileManager.listFiles({ + const filesResult = await this.listFiles.execute({ where: { OR: [{ key: query }, { aliases_contains: query }] }, limit: 1 }); + const files = filesResult.value.items; + return files.length ? files[0] : undefined; } } diff --git a/packages/api-file-manager/src/graphql/index.ts b/packages/api-file-manager/src/graphql/index.ts index 2a781c5aec6..78c4ab8a405 100644 --- a/packages/api-file-manager/src/graphql/index.ts +++ b/packages/api-file-manager/src/graphql/index.ts @@ -1,27 +1,27 @@ import { ContextPlugin } from "@webiny/api"; -import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; -import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { ListModelsUseCase } from "@webiny/api-headless-cms/features/contentModel/ListModels/index.js"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; import { createGraphQLSchemaPluginFromFieldPlugins } from "@webiny/api-headless-cms/utils/getSchemaFromFieldPlugins.js"; import { createBaseSchema } from "~/graphql/baseSchema.js"; -import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; import { createFilesSchema } from "~/graphql/filesSchema.js"; import { getFileByUrl } from "~/graphql/getFileByUrl.js"; -import type { FileManagerContext } from "~/types.js"; +import { FileModel } from "~/domain/file/abstractions.js"; export const createGraphQLSchemaPlugin = () => { return [ createBaseSchema(), // Files schema is generated dynamically, based on a CMS model, so we need to // register it from a ContextPlugin, to perform additional bootstrap. - new ContextPlugin(async context => { - if (!(await isHeadlessCmsReady(context))) { - return; - } + new ContextPlugin(async context => { + const fileModel = context.container.resolve(FileModel); + const listModels = context.container.resolve(ListModelsUseCase); await context.security.withoutAuthorization(async () => { - const fileModel = (await context.cms.getModel("fmFile")) as CmsModel; - const models = await context.cms.listModels(); + const modelsResult = await listModels.execute(); + const models = modelsResult.value; + const fieldPlugins = createFieldTypePluginRecords(context.plugins); /** * We need to register all plugins for all the CMS fields. diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index 551c32c3e49..06b17ac4743 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -1,13 +1,13 @@ +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; import { ContextPlugin } from "@webiny/api"; import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; -import type { FileManagerContext, FilePermission, SettingsPermission } from "~/types.js"; +import type { FilePermission, SettingsPermission } from "~/types.js"; import type { AssetDeliveryParams } from "./delivery/setupAssetDelivery.js"; import { setupAssetDelivery } from "./delivery/setupAssetDelivery.js"; import { createGraphQLSchemaPlugin } from "./graphql/index.js"; -import type { FileManagerConfig } from "./createFileManager/types.js"; import { FileManagerFeature } from "~/features/FileManagerFeature.js"; -import { FilesPermissions as FilePermissionsImpl } from "~/createFileManager/permissions/FilesPermissions.js"; -import { SettingsPermissions as SettingsPermissionsImpl } from "~/createFileManager/permissions/SettingsPermissions.js"; +import { FilesPermissions as FilePermissionsImpl } from "~/permissions/FilesPermissions.js"; +import { SettingsPermissions as SettingsPermissionsImpl } from "~/permissions/SettingsPermissions.js"; import { FilePermissions, SettingsPermissions } from "~/features/shared/abstractions.js"; import { createFileModel, FILE_MODEL_ID } from "~/domain/file/fileModel.js"; import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; @@ -15,18 +15,10 @@ import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/ import { FileModel } from "~/domain/file/abstractions.js"; export * from "./modelModifier/CmsModelModifier.js"; -export * from "./plugins/index.js"; export * from "./delivery/index.js"; -export const createFileManagerContext = ({ - storageOperations -}: Pick) => { - const plugin = new ContextPlugin(async context => { - // TODO: implements as a decorator - // if (context.wcp.canUseFileManagerThreatDetection()) { - // context.fileManager = applyThreatScanning(context.fileManager); - // } - +export const createFileManagerContext = () => { + const plugin = new ContextPlugin(async context => { const getModel = context.container.resolve(GetModelUseCase); const wcpContext = context.container.resolve(WcpContext); const withPrivateFiles = wcpContext.canUsePrivateFiles(); @@ -34,8 +26,10 @@ export const createFileManagerContext = ({ const fileModelDefinition = createFileModel({ withPrivateFiles }); context.plugins.register(fileModelDefinition); - const fileModel = await getModel.execute(FILE_MODEL_ID); - context.container.registerInstance(FileModel, fileModel.value); + await context.security.withoutAuthorization(async () => { + const fileModel = await getModel.execute(FILE_MODEL_ID); + context.container.registerInstance(FileModel, fileModel.value); + }); const identityContext = context.container.resolve(IdentityContext); diff --git a/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts b/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts index 45585ee8c5e..89649c29ca5 100644 --- a/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts +++ b/packages/api-file-manager/src/modelModifier/CmsModelModifier.ts @@ -2,7 +2,7 @@ import { Plugin } from "@webiny/plugins"; import type { CmsModelField as BaseModelField } from "@webiny/api-headless-cms/types/index.js"; import type { CmsPrivateModelFull } from "@webiny/api-headless-cms"; import { createModelField } from "@webiny/api-headless-cms"; -import { FILE_MODEL_ID } from "~/domain/file/fileModel"; +import { FILE_MODEL_ID } from "~/domain/file/fileModel.js"; type CmsModelField = Omit & { bulkEdit?: boolean }; diff --git a/packages/api-file-manager/src/createFileManager/permissions/FilesPermissions.ts b/packages/api-file-manager/src/permissions/FilesPermissions.ts similarity index 100% rename from packages/api-file-manager/src/createFileManager/permissions/FilesPermissions.ts rename to packages/api-file-manager/src/permissions/FilesPermissions.ts diff --git a/packages/api-file-manager/src/createFileManager/permissions/SettingsPermissions.ts b/packages/api-file-manager/src/permissions/SettingsPermissions.ts similarity index 100% rename from packages/api-file-manager/src/createFileManager/permissions/SettingsPermissions.ts rename to packages/api-file-manager/src/permissions/SettingsPermissions.ts diff --git a/packages/api-file-manager/src/plugins/FilePhysicalStoragePlugin.ts b/packages/api-file-manager/src/plugins/FilePhysicalStoragePlugin.ts deleted file mode 100644 index 5c5849a63fc..00000000000 --- a/packages/api-file-manager/src/plugins/FilePhysicalStoragePlugin.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Plugin } from "@webiny/plugins"; -import WebinyError from "@webiny/error"; -import type { FileManagerSettings } from "~/types.js"; - -export interface FilePhysicalStoragePluginParams< - U extends FilePhysicalStoragePluginUploadParams, - D extends FilePhysicalStoragePluginDeleteParams -> { - upload: (args: U) => Promise; - delete: (args: D) => Promise; -} - -export interface FilePhysicalStoragePluginUploadParams { - settings: FileManagerSettings; - buffer: Buffer; -} - -export interface FilePhysicalStoragePluginDeleteParams { - key: string; -} - -export class FilePhysicalStoragePlugin< - U extends FilePhysicalStoragePluginUploadParams = FilePhysicalStoragePluginUploadParams, - D extends FilePhysicalStoragePluginDeleteParams = FilePhysicalStoragePluginDeleteParams -> extends Plugin { - public static override readonly type: string = "api-file-manager-storage"; - private readonly _params: FilePhysicalStoragePluginParams; - - public constructor(params: FilePhysicalStoragePluginParams) { - super(); - this._params = params; - } - - public async upload(params: U): Promise { - if (!this._params.upload) { - throw new WebinyError( - `You must define the "upload" method of this plugin.`, - "UPLOAD_METHOD_ERROR" - ); - } - return this._params.upload(params); - } - - public async delete(params: D): Promise { - if (!this._params.delete) { - throw new WebinyError( - `You must define the "delete" method of this plugin.`, - "DELETE_METHOD_ERROR" - ); - } - return this._params.delete(params); - } -} diff --git a/packages/api-file-manager/src/plugins/FileStorageTransformPlugin.ts b/packages/api-file-manager/src/plugins/FileStorageTransformPlugin.ts deleted file mode 100644 index 7fda8531f3e..00000000000 --- a/packages/api-file-manager/src/plugins/FileStorageTransformPlugin.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Plugin } from "@webiny/plugins"; -import type { File } from "~/types.js"; - -export interface FileStorageTransformPluginToParams { - /** - * File that is being sent to the storage operations method. - */ - file: File & Record; -} - -export interface FileStorageTransformPluginFromParams { - /** - * File that was fetched from the storage operations method. - */ - file: File & Record; -} - -export interface FileStorageTransformPluginParams { - toStorage?: (params: FileStorageTransformPluginToParams) => Promise>; - fromStorage?: ( - params: FileStorageTransformPluginFromParams - ) => Promise>; -} - -export class FileStorageTransformPlugin extends Plugin { - public static override readonly type: string = "fm.files.storage.transform"; - private readonly _params: FileStorageTransformPluginParams; - - public constructor(params: FileStorageTransformPluginParams) { - super(); - - this._params = params; - } - - /** - * Transform the file value into something that can be stored. - * Be aware that you must return the whole file object. - */ - public async toStorage( - params: FileStorageTransformPluginToParams - ): Promise> { - if (!this._params.toStorage) { - return params.file; - } - return this._params.toStorage(params); - } - /** - * Transform the file value from the storage type to one required by our system. - * Be aware that you must return the whole file object. - * This method MUST reverse what ever toStorage method changed on the file object. - */ - public async fromStorage( - params: FileStorageTransformPluginFromParams - ): Promise> { - if (!this._params.fromStorage) { - return params.file; - } - return this._params.fromStorage(params); - } -} diff --git a/packages/api-file-manager/src/plugins/index.ts b/packages/api-file-manager/src/plugins/index.ts deleted file mode 100644 index b2c732faae7..00000000000 --- a/packages/api-file-manager/src/plugins/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./FilePhysicalStoragePlugin.js"; -export * from "./FileStorageTransformPlugin.js"; diff --git a/packages/api-file-manager/src/storage/FileStorage.ts b/packages/api-file-manager/src/storage/FileStorage.ts deleted file mode 100644 index 09c38e07182..00000000000 --- a/packages/api-file-manager/src/storage/FileStorage.ts +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck TODO: remove this file -import type { FileManagerContext } from "~/types.js"; -import WebinyError from "@webiny/error"; -import type { FilePhysicalStoragePlugin } from "~/plugins/FilePhysicalStoragePlugin.js"; - - -/** - * TODO: implement this via a separate service to delete file from storage. - * TODO: The service should be called as an event handled on successful file deletion from DB - */ - -export class FileStorage { - private readonly context: FileManagerContext; - - constructor({ context }: FileStorageParams) { - this.context = context; - } - - get storagePlugin() { - const storagePlugin = this.context.plugins - .byType(storagePluginType) - .pop(); - - if (!storagePlugin) { - throw new WebinyError( - `Missing plugin of type "${storagePluginType}".`, - "STORAGE_PLUGIN_ERROR" - ); - } - - return storagePlugin; - } -} diff --git a/packages/api-file-manager/src/types.ts b/packages/api-file-manager/src/types.ts index 776982f4bc6..9dcaaaa6122 100644 --- a/packages/api-file-manager/src/types.ts +++ b/packages/api-file-manager/src/types.ts @@ -1,23 +1,14 @@ -import type { FileStorage } from "./storage/FileStorage.js"; -import type { Context } from "@webiny/api/types.js"; -import type { FileLifecycleEvents } from "./types/file.lifecycle.js"; -import type { CreatedBy, File } from "./types/file.js"; -import type { Topic } from "@webiny/pubsub/types.js"; -import type { CmsContext, CmsEntryListSort } from "@webiny/api-headless-cms/types/index.js"; -import type { Context as TasksContext } from "@webiny/tasks/types.js"; -import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import { File } from "~/domain/file/types.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; -export type * from "./types/file.lifecycle.js"; -export type * from "./types/file.js"; -export type * from "./types/file.js"; - -export interface FileManagerContextObject extends FilesCRUD, SettingsCRUD { - storage: FileStorage; +export interface FileStorageDto extends File { + tenant: string; } -export interface FileManagerContext extends Context, ApiCoreContext, CmsContext, TasksContext { - fileManager: FileManagerContextObject; +export interface FileAliasStorageDto { + tenant: string; + fileId: string; + alias: string; } export interface FilePermission extends SecurityPermission { @@ -30,127 +21,7 @@ export interface SettingsPermission extends SecurityPermission { name: "fm.setting"; } -export interface FileInput { - id: string; - - // In the background, we're actually mapping these to entry-level fields. - // This is fine since we don't use revisions for files. - createdOn?: string | Date | null; - modifiedOn?: string | Date | null; - savedOn?: string | Date | null; - createdBy?: CreatedBy | null; - modifiedBy?: CreatedBy | null; - savedBy?: CreatedBy | null; - - key: string; - name: string; - size: number; - type: string; - meta: Record; - location?: { - folderId: string; - }; - tags: string[]; - aliases: string[]; - extensions?: Record; -} - -export interface FileListWhereParams { - AND?: FileListWhereParams[]; - OR?: FileListWhereParams[]; - [key: string]: any; -} -export interface FilesListOpts { - search?: string; - limit?: number; - after?: string; - where?: FileListWhereParams; - sort?: CmsEntryListSort; -} - -export interface FileListMeta { - cursor: string | null; - totalCount: number; - hasMoreItems: boolean; -} - -interface FilesCrudListTagsWhere { - tag?: string; - tag_contains?: string; - tag_in?: string[]; - tag_not_startsWith?: string; - tag_startsWith?: string; -} -interface FilesCrudListTagsParams { - where?: FilesCrudListTagsWhere; - limit?: number; - after?: string; -} - -export interface ListTagsResponse { - tag: string; - count: number; -} -export interface FilesCRUD extends FileLifecycleEvents { - getFile(id: string): Promise; - listFiles(opts?: FilesListOpts): Promise<[File[], FileListMeta]>; - listTags(params: FilesCrudListTagsParams): Promise; - createFile(data: FileInput, meta?: Record): Promise; - updateFile(id: string, data: Partial): Promise; - deleteFile(id: string): Promise; - createFilesInBatch(data: FileInput[], meta?: Record): Promise; -} - -export interface FileManagerSettings { - tenant: string; - uploadMinFileSize: number; - uploadMaxFileSize: number; - srcPrefix: string; -} - -export interface OnSettingsBeforeUpdateTopicParams { - input: Partial; - original: FileManagerSettings; - settings: FileManagerSettings; -} - -export interface OnSettingsAfterUpdateTopicParams { - input: Partial; - original: FileManagerSettings; - settings: FileManagerSettings; -} - -export type SettingsCRUD = { - getSettings(): Promise; - createSettings(data?: Partial): Promise; - updateSettings(data: Partial): Promise; - deleteSettings(): Promise; - - onSettingsBeforeUpdate: Topic; - onSettingsAfterUpdate: Topic; -}; -/******** - * Storage operations - *******/ - -/** - * @category StorageOperations - * @category FilesStorageOperations - * @category FilesStorageOperationsParams - */ -export interface FileManagerFilesStorageOperationsListParamsWhere { - [key: string]: any; -} - -export interface FileManagerFilesStorageOperationsTagsParamsWhere extends FilesCrudListTagsWhere { - tenant: string; -} - -export interface FileAliasesStorageOperations { - storeAliases(file: File): Promise; - deleteAliases(file: File): Promise; -} - -export interface FileManagerStorageOperations { - aliases: FileAliasesStorageOperations; +export interface FileAliasStorageOperations { + storeAliases(file: FileStorageDto): Promise; + deleteAliases(file: FileStorageDto): Promise; } diff --git a/packages/api-file-manager/src/types/file.lifecycle.ts b/packages/api-file-manager/src/types/file.lifecycle.ts deleted file mode 100644 index b2cdef8d9bd..00000000000 --- a/packages/api-file-manager/src/types/file.lifecycle.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Topic } from "@webiny/pubsub/types.js"; -import type { File } from "./file.js"; - -export interface OnFileBeforeCreateTopicParams { - file: TFile; - meta?: Record; -} - -export interface OnFileAfterCreateTopicParams { - file: TFile; - meta?: Record; -} - -export interface OnFileBeforeBatchCreateTopicParams { - files: TFile[]; - meta?: Record; -} - -export interface OnFileAfterBatchCreateTopicParams { - files: TFile[]; - meta?: Record; -} - -export interface OnFileBeforeUpdateTopicParams { - original: TFile; - file: TFile; - input: Record; -} - -export interface OnFileAfterUpdateTopicParams { - original: TFile; - file: TFile; - input: Record; -} - -export interface OnFileBeforeDeleteTopicParams { - file: TFile; -} - -export interface OnFileAfterDeleteTopicParams { - file: TFile; -} - -export interface FileLifecycleEvents { - onFileBeforeCreate: Topic; - onFileAfterCreate: Topic; - onFileBeforeBatchCreate: Topic; - onFileAfterBatchCreate: Topic; - onFileBeforeUpdate: Topic; - onFileAfterUpdate: Topic; - onFileBeforeDelete: Topic; - onFileAfterDelete: Topic; -} diff --git a/packages/api-file-manager/src/types/file.ts b/packages/api-file-manager/src/types/file.ts deleted file mode 100644 index 9bed9b9bd6b..00000000000 --- a/packages/api-file-manager/src/types/file.ts +++ /dev/null @@ -1,53 +0,0 @@ -type PublicAccess = { - type: "public"; -}; - -type PrivateAuthenticatedAccess = { - type: "private-authenticated"; -}; - -export interface File { - id: string; - key: string; - size: number; - type: string; - name: string; - meta: Record; - accessControl?: PublicAccess | PrivateAuthenticatedAccess; - location: { - folderId: string; - }; - tags: string[]; - aliases: string[]; - - createdOn: string; - modifiedOn: string | null; - savedOn: string; - createdBy: CreatedBy; - modifiedBy: CreatedBy | null; - savedBy: CreatedBy; - extensions?: Record; - - /** - * Added with new storage operations refactoring. - */ - tenant: string; - webinyVersion: string; - /** - * User can add new fields to the File object, so we must allow it in the types. - */ - - [key: string]: any; -} - -export interface FileAlias { - tenant: string; - fileId: string; - alias: string; -} - -export interface CreatedBy { - id: string; - displayName: string | null; - type: string; -} diff --git a/packages/api-headless-cms/src/types/identity.ts b/packages/api-headless-cms/src/types/identity.ts index e38598e99c5..60579c08794 100644 --- a/packages/api-headless-cms/src/types/identity.ts +++ b/packages/api-headless-cms/src/types/identity.ts @@ -11,7 +11,7 @@ export interface CmsIdentity { /** * Full name of the user. */ - displayName: string | null; + displayName: string; /** * Type of the user (admin, user) */ diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts index 30bd36791fe..1b641feab45 100644 --- a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -1,5 +1,5 @@ import { Result } from "@webiny/feature/api"; -import { WebsocketsContext } from "@webiny/api-websockets/features/WebsocketsContext"; +import { WebsocketService } from "@webiny/api-websockets/features/WebsocketService"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { parseIdentifier } from "@webiny/utils"; import { KickOutCurrentUserUseCase as UseCaseAbstraction } from "./abstractions.js"; @@ -8,11 +8,11 @@ import type { ILockRecord } from "~/domain/index.js"; class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { constructor( private identityContext: IdentityContext.Interface, - private websocketsContext?: WebsocketsContext.Interface + private websocketService?: WebsocketService.Interface ) {} async execute(record: ILockRecord): Promise> { - if (!this.websocketsContext) { + if (!this.websocketService) { return Result.ok(); } @@ -25,7 +25,7 @@ class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { * We do not want any errors to leak out of this method. */ try { - await this.websocketsContext.send( + await this.websocketService.send( { id: lockedBy.id }, { action: `recordLocking.entry.kickOut.${entryId}`, @@ -48,5 +48,5 @@ class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { export const KickOutCurrentUserUseCase = UseCaseAbstraction.createImplementation({ implementation: KickOutCurrentUserUseCaseImpl, - dependencies: [IdentityContext, [WebsocketsContext, { optional: true }]] + dependencies: [IdentityContext, [WebsocketService, { optional: true }]] }); diff --git a/packages/api-record-locking/tsconfig.build.json b/packages/api-record-locking/tsconfig.build.json index 5a8028070cf..c91653e25bf 100644 --- a/packages/api-record-locking/tsconfig.build.json +++ b/packages/api-record-locking/tsconfig.build.json @@ -26,8 +26,8 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/api-record-locking/tsconfig.json b/packages/api-record-locking/tsconfig.json index 3f52e311bf9..eebdc8676fe 100644 --- a/packages/api-record-locking/tsconfig.json +++ b/packages/api-record-locking/tsconfig.json @@ -26,8 +26,8 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json index e604128401e..d4a4190cc14 100644 --- a/packages/api-websockets/package.json +++ b/packages/api-websockets/package.json @@ -12,7 +12,7 @@ "Bruno Zorić " ], "exports": { - "./features/WebsocketsContext": "./src/features/WebsocketsContext/index.js", + "./features/WebsocketService": "./src/features/WebsocketService/index.js", "./*": "./src/*", ".": "./src/index.js" }, diff --git a/packages/api-websockets/src/context/index.ts b/packages/api-websockets/src/context/index.ts index 1efe9a1593c..9b5f77d1486 100644 --- a/packages/api-websockets/src/context/index.ts +++ b/packages/api-websockets/src/context/index.ts @@ -3,7 +3,7 @@ import type { Context } from "~/types.js"; import { WebsocketsContext as WebsocketsImplementation } from "./WebsocketsContext.js"; import { WebsocketsConnectionRegistry } from "~/registry/index.js"; import { WebsocketsTransport } from "~/transport/index.js"; -import { WebsocketsContext } from "~/features/WebsocketsContext/abstractions.js"; +import { WebsocketService } from "~/features/WebsocketService/abstractions.js"; export type * from "./abstractions/IWebsocketsContext.js"; @@ -18,7 +18,7 @@ export const createWebsocketsContext = () => { const transport = new WebsocketsTransport(); context.websockets = new WebsocketsImplementation(registry, transport); - context.container.registerInstance(WebsocketsContext, context.websockets); + context.container.registerInstance(WebsocketService, context.websockets); }); plugin.name = "websockets.context"; diff --git a/packages/api-websockets/src/features/WebsocketsContext/abstractions.ts b/packages/api-websockets/src/features/WebsocketService/abstractions.ts similarity index 60% rename from packages/api-websockets/src/features/WebsocketsContext/abstractions.ts rename to packages/api-websockets/src/features/WebsocketService/abstractions.ts index c1c0797735d..819015b7df8 100644 --- a/packages/api-websockets/src/features/WebsocketsContext/abstractions.ts +++ b/packages/api-websockets/src/features/WebsocketService/abstractions.ts @@ -1,8 +1,8 @@ import { createAbstraction } from "@webiny/feature/api"; import type { IWebsocketsContextObject } from "~/context/abstractions/IWebsocketsContext.js"; -export const WebsocketsContext = createAbstraction("WebsocketsContext"); +export const WebsocketService = createAbstraction("WebsocketService"); -export namespace WebsocketsContext { +export namespace WebsocketService { export type Interface = IWebsocketsContextObject; } diff --git a/packages/api-websockets/src/features/WebsocketService/index.ts b/packages/api-websockets/src/features/WebsocketService/index.ts new file mode 100644 index 00000000000..9a7b88524b4 --- /dev/null +++ b/packages/api-websockets/src/features/WebsocketService/index.ts @@ -0,0 +1 @@ +export { WebsocketService } from "./abstractions.js"; diff --git a/packages/api-websockets/src/features/WebsocketsContext/index.ts b/packages/api-websockets/src/features/WebsocketsContext/index.ts deleted file mode 100644 index 3e217c2a8e9..00000000000 --- a/packages/api-websockets/src/features/WebsocketsContext/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { WebsocketsContext } from "./abstractions.js"; diff --git a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts index 070afd79965..e4124bf5e64 100644 --- a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts +++ b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts @@ -8,7 +8,7 @@ import { DynamoDbDriver } from "@webiny/db-dynamodb"; import dynamoDbPlugins from "@webiny/db-dynamodb/plugins"; import { createFileManagerContext, createFileManagerGraphQL } from "@webiny/api-file-manager"; import { createFileManagerStorageOperations } from "@webiny/api-file-manager-ddb"; -import fileManagerS3, { createAssetDelivery } from "@webiny/api-file-manager-s3"; +import { createFileManagerS3, createAssetDelivery } from "@webiny/api-file-manager-s3"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb"; import { createHcmsTasks } from "@webiny/api-headless-cms-tasks"; @@ -61,7 +61,7 @@ export const handler = createHandler({ }), createFileManagerGraphQL(), createAssetDelivery({ documentClient }), - fileManagerS3(), + createFileManagerS3(), createAco({ documentClient }), createWorkflows(), createHeadlessCmsWorkflows(), diff --git a/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts b/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts index 39b515e51b0..cfd36482d62 100644 --- a/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts +++ b/packages/project-aws/_templates/extensions/OpenSearch/api/graphql/src/index.ts @@ -9,7 +9,7 @@ import dynamoDbPlugins from "@webiny/db-dynamodb/plugins"; import elasticsearchClientContext, { createElasticsearchClient } from "@webiny/api-elasticsearch"; import { createFileManagerContext, createFileManagerGraphQL } from "@webiny/api-file-manager"; import { createFileManagerStorageOperations } from "@webiny/api-file-manager-ddb"; -import fileManagerS3, { createAssetDelivery } from "@webiny/api-file-manager-s3"; +import { createFileManagerS3, createAssetDelivery } from "@webiny/api-file-manager-s3"; import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { createStorageOperations as createHeadlessCmsStorageOperations } from "@webiny/api-headless-cms-ddb-es"; import { createHcmsTasks } from "@webiny/api-headless-cms-tasks-ddb-es"; @@ -73,7 +73,7 @@ export const handler = createHandler({ }), createFileManagerGraphQL(), createAssetDelivery({ documentClient }), - fileManagerS3(), + createFileManagerS3(), createAco({ documentClient }), diff --git a/packages/project-aws/tsconfig.build.json b/packages/project-aws/tsconfig.build.json index db1ae8956c8..79e9eec220d 100644 --- a/packages/project-aws/tsconfig.build.json +++ b/packages/project-aws/tsconfig.build.json @@ -247,8 +247,8 @@ "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/project-aws/tsconfig.json b/packages/project-aws/tsconfig.json index fe1f5b37b23..fb769c8379c 100644 --- a/packages/project-aws/tsconfig.json +++ b/packages/project-aws/tsconfig.json @@ -247,8 +247,8 @@ "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/tasks/package.json b/packages/tasks/package.json index 250caaa3425..74c00b0a3ef 100644 --- a/packages/tasks/package.json +++ b/packages/tasks/package.json @@ -18,6 +18,7 @@ "@webiny/api-headless-cms": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/handler-graphql": "0.0.0", diff --git a/packages/tasks/src/context.ts b/packages/tasks/src/context.ts index 2dfc663b8a9..eed9b607fb0 100644 --- a/packages/tasks/src/context.ts +++ b/packages/tasks/src/context.ts @@ -7,6 +7,7 @@ import { createServiceCrud } from "~/crud/service.tasks.js"; import { createTaskCrud } from "./crud/crud.tasks.js"; import { createTestingRunTask } from "~/tasks/testingRunTask.js"; import { createServicePlugins } from "~/service/index.js"; +import { TaskService } from "~/features/TaskService/abstractions.js"; const createTasksCrud = () => { const plugin = new ContextPlugin(async context => { @@ -27,5 +28,12 @@ const createTasksContext = (): Plugin[] => { }; export const createBackgroundTaskContext = (): Plugin[] => { - return [createTestingRunTask(), ...createTasksContext()]; + return [ + createTestingRunTask(), + ...createTasksContext(), + new ContextPlugin(context => { + // Register legacy tasks context via a new abstraction + context.container.registerInstance(TaskService, context.tasks); + }) + ]; }; diff --git a/packages/tasks/src/features/TaskService/abstractions.ts b/packages/tasks/src/features/TaskService/abstractions.ts new file mode 100644 index 00000000000..d5fd244e4f7 --- /dev/null +++ b/packages/tasks/src/features/TaskService/abstractions.ts @@ -0,0 +1,8 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { ITasksContextServiceObject } from "~/types.js"; + +export const TaskService = createAbstraction("TaskService"); + +export namespace TaskService { + export type Interface = ITasksContextServiceObject; +} diff --git a/packages/tasks/tsconfig.build.json b/packages/tasks/tsconfig.build.json index 50b14b84a58..371cbd8943b 100644 --- a/packages/tasks/tsconfig.build.json +++ b/packages/tasks/tsconfig.build.json @@ -7,6 +7,7 @@ { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, @@ -155,6 +156,10 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/tasks/tsconfig.json b/packages/tasks/tsconfig.json index 484dbf08645..2d8d24be8b1 100644 --- a/packages/tasks/tsconfig.json +++ b/packages/tasks/tsconfig.json @@ -7,6 +7,7 @@ { "path": "../api-headless-cms" }, { "path": "../aws-sdk" }, { "path": "../error" }, + { "path": "../feature" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../handler-graphql" }, @@ -155,6 +156,10 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/testing/tsconfig.build.json b/packages/testing/tsconfig.build.json index b5e6d423960..3bf1618ebc5 100644 --- a/packages/testing/tsconfig.build.json +++ b/packages/testing/tsconfig.build.json @@ -204,8 +204,8 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index 3a92897cbc1..7e14f593222 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -204,8 +204,8 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketsContext": [ - "../api-websockets/src/src/features/WebsocketsContext/index.js" + "@webiny/api-websockets/features/WebsocketService": [ + "../api-websockets/src/src/features/WebsocketService/index.js" ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], diff --git a/yarn.lock b/yarn.lock index cb928598b23..d2fa1b5fb26 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17004,6 +17004,7 @@ __metadata: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/error": "npm:0.0.0" + "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" From 5af355e3694e8e7ba106bca7e981f2b3bcde4302 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Sun, 30 Nov 2025 23:09:54 +0100 Subject: [PATCH 61/71] wip: migrate file manager --- .../FmFolderBeforeDeleteHandler.ts | 10 +- packages/api-aco/src/types.ts | 4 +- .../src/subscriptions/fileManager/files.ts | 85 +++-- .../src/subscriptions/fileManager/settings.ts | 33 +- packages/api-audit-logs/src/types.ts | 2 - packages/api-file-manager-ddb/src/index.ts | 8 +- .../createThreatDetectionEventHandler.ts | 5 +- .../processThreatScanResult.ts | 5 +- .../assetDelivery/threatDetection/types.ts | 5 - .../src/flushCdnCache/CdnPathsGenerator.ts | 2 +- .../src/flushCdnCache/InvalidateCacheTask.ts | 3 +- .../invalidateCacheTaskDefinition.ts | 3 +- .../src/utils/getPresignedPostPayload.ts | 2 +- .../src/createFileManager/files.crud.ts | 358 ------------------ .../features/file/CreateFile/abstractions.ts | 1 + packages/api-file-manager/src/types.ts | 1 + .../ExportContentAssets.ts | 10 +- .../ImportFromUrlProcessAssets.ts | 53 +-- .../utils/cmsAssetsZipper/CmsAssetsZipper.ts | 3 +- .../utils/entryAssets/EntryAssetsResolver.ts | 45 +-- .../abstractions/EntryAssetsResolver.ts | 13 +- .../src/types.ts | 4 +- .../src/crud/AccessControl/AccessControl.ts | 18 + packages/tasks/src/types.ts | 2 +- 24 files changed, 175 insertions(+), 500 deletions(-) delete mode 100644 packages/api-file-manager/src/createFileManager/files.crud.ts diff --git a/packages/api-aco/src/features/folders/EnsureFmFolderIsEmptyOnDelete/FmFolderBeforeDeleteHandler.ts b/packages/api-aco/src/features/folders/EnsureFmFolderIsEmptyOnDelete/FmFolderBeforeDeleteHandler.ts index 0e852f7a03b..83f1afacb5d 100644 --- a/packages/api-aco/src/features/folders/EnsureFmFolderIsEmptyOnDelete/FmFolderBeforeDeleteHandler.ts +++ b/packages/api-aco/src/features/folders/EnsureFmFolderIsEmptyOnDelete/FmFolderBeforeDeleteHandler.ts @@ -3,13 +3,17 @@ import { FolderBeforeDeleteHandler } from "~/features/folders/DeleteFolder/abstr import type { FolderBeforeDeleteEvent } from "~/features/folders/DeleteFolder/events.js"; import { ensureFolderIsEmpty } from "~/folder/ensureFolderIsEmpty.js"; import type { AcoContext } from "~/types.js"; +import { ListFilesUseCase } from "@webiny/api-file-manager/features/file/ListFiles/index.js"; +// TODO: refactor this handler to remove the need for `context` export class FmFolderBeforeDeleteHandler implements FolderBeforeDeleteHandler.Interface { constructor(private context: AcoContext) {} async handle(event: FolderBeforeDeleteEvent): Promise { const { folder } = event.payload; + const listFiles = this.context.container.resolve(ListFilesUseCase); + try { const { id, type } = folder; @@ -24,7 +28,7 @@ export class FmFolderBeforeDeleteHandler implements FolderBeforeDeleteHandler.In context: this.context, folder, hasContentCallback: async () => { - const [content] = await this.context.fileManager.listFiles({ + const result = await listFiles.execute({ where: { location: { folderId: id @@ -33,7 +37,9 @@ export class FmFolderBeforeDeleteHandler implements FolderBeforeDeleteHandler.In limit: 1 }); - return content.length > 0; + const { items } = result.value; + + return items.length > 0; } }); } catch (error) { diff --git a/packages/api-aco/src/types.ts b/packages/api-aco/src/types.ts index dfdba8db72a..64eef74a25e 100644 --- a/packages/api-aco/src/types.ts +++ b/packages/api-aco/src/types.ts @@ -1,5 +1,4 @@ import type { Context as BaseContext } from "@webiny/handler/types.js"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import type { Context as TasksContext } from "@webiny/tasks/types.js"; import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; import type { AcoFolderCrud, AcoFolderStorageOperations } from "~/folder/folder.types.js"; @@ -21,7 +20,7 @@ export type * from "./flp/flp.types.js"; export interface User { id: string; type: string; - displayName: string | null; + displayName: string; } export interface ListMeta { @@ -72,7 +71,6 @@ export interface AcoContext extends BaseContext, ApiCoreContext, CmsContext, - FileManagerContext, TasksContext { aco: AdvancedContentOrganisation; } diff --git a/packages/api-audit-logs/src/subscriptions/fileManager/files.ts b/packages/api-audit-logs/src/subscriptions/fileManager/files.ts index ba88b7ae1f5..2e9030ed7f8 100644 --- a/packages/api-audit-logs/src/subscriptions/fileManager/files.ts +++ b/packages/api-audit-logs/src/subscriptions/fileManager/files.ts @@ -3,53 +3,68 @@ import WebinyError from "@webiny/error"; import { AUDIT } from "~/config.js"; import { getAuditConfig } from "~/utils/getAuditConfig.js"; import type { AuditLogsContext } from "~/types.js"; +import { FileAfterCreateHandler } from "@webiny/api-file-manager/features/file/CreateFile/events.js"; +import { FileAfterUpdateHandler } from "@webiny/api-file-manager/features/file/UpdateFile/events.js"; +import { FileAfterDeleteHandler } from "@webiny/api-file-manager/features/file/DeleteFile/events.js"; export const onFileAfterCreateHook = (context: AuditLogsContext) => { - context.fileManager.onFileAfterCreate.subscribe(async ({ file }) => { - try { - const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.CREATE); - - await createAuditLog("File created", file, file.id, context); - } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing onFileAfterCreateHook hook", - code: "AUDIT_LOGS_AFTER_FILE_CREATE_HOOK" - }); + context.container.registerInstance(FileAfterCreateHandler, { + handle: async event => { + const { file } = event.payload; + + try { + const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.CREATE); + + await createAuditLog("File created", file, file.id, context); + } catch (error) { + throw WebinyError.from(error, { + message: "Error while executing onFileAfterCreateHook hook", + code: "AUDIT_LOGS_AFTER_FILE_CREATE_HOOK" + }); + } } }); }; export const onFileAfterUpdateHook = (context: AuditLogsContext) => { - context.fileManager.onFileAfterUpdate.subscribe(async ({ file, original }) => { - try { - const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.UPDATE); - - await createAuditLog( - "File updated", - { before: original, after: file }, - file.id, - context - ); - } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing onFileAfterUpdateHook hook", - code: "AUDIT_LOGS_AFTER_FILE_UPDATE_HOOK" - }); + context.container.registerInstance(FileAfterUpdateHandler, { + handle: async event => { + const { file, original } = event.payload; + + try { + const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.UPDATE); + + await createAuditLog( + "File updated", + { before: original, after: file }, + file.id, + context + ); + } catch (error) { + throw WebinyError.from(error, { + message: "Error while executing onFileAfterUpdateHook hook", + code: "AUDIT_LOGS_AFTER_FILE_UPDATE_HOOK" + }); + } } }); }; export const onFileAfterDeleteHook = (context: AuditLogsContext) => { - context.fileManager.onFileAfterDelete.subscribe(async ({ file }) => { - try { - const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.DELETE); - - await createAuditLog("File deleted", file, file.id, context); - } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing onFileAfterDeleteHook hook", - code: "AUDIT_LOGS_AFTER_FILE_DELETE_HOOK" - }); + context.container.registerInstance(FileAfterDeleteHandler, { + handle: async event => { + const { file } = event.payload; + + try { + const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.FILE.DELETE); + + await createAuditLog("File deleted", file, file.id, context); + } catch (error) { + throw WebinyError.from(error, { + message: "Error while executing onFileAfterDeleteHook hook", + code: "AUDIT_LOGS_AFTER_FILE_DELETE_HOOK" + }); + } } }); }; diff --git a/packages/api-audit-logs/src/subscriptions/fileManager/settings.ts b/packages/api-audit-logs/src/subscriptions/fileManager/settings.ts index 8e69f6acf82..65195d4bc86 100644 --- a/packages/api-audit-logs/src/subscriptions/fileManager/settings.ts +++ b/packages/api-audit-logs/src/subscriptions/fileManager/settings.ts @@ -3,23 +3,28 @@ import WebinyError from "@webiny/error"; import { AUDIT } from "~/config.js"; import { getAuditConfig } from "~/utils/getAuditConfig.js"; import type { AuditLogsContext } from "~/types.js"; +import { SettingsAfterUpdateHandler } from "@webiny/api-file-manager/features/settings/UpdateSettings/events.js"; export const onSettingsAfterUpdateHook = (context: AuditLogsContext) => { - context.fileManager.onSettingsAfterUpdate.subscribe(async ({ settings, original }) => { - try { - const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.SETTINGS.UPDATE); + context.container.registerInstance(SettingsAfterUpdateHandler, { + handle: async event => { + const { settings, original } = event.payload; - await createAuditLog( - "Settings updated", - { before: original, after: settings }, - "-", - context - ); - } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing onSettingsAfterUpdateHook hook", - code: "AUDIT_LOGS_AFTER_SETTINGS_UPDATE_HOOK" - }); + try { + const createAuditLog = getAuditConfig(AUDIT.FILE_MANAGER.SETTINGS.UPDATE); + + await createAuditLog( + "Settings updated", + { before: original, after: settings }, + "-", + context + ); + } catch (error) { + throw WebinyError.from(error, { + message: "Error while executing onSettingsAfterUpdateHook hook", + code: "AUDIT_LOGS_AFTER_SETTINGS_UPDATE_HOOK" + }); + } } }); }; diff --git a/packages/api-audit-logs/src/types.ts b/packages/api-audit-logs/src/types.ts index aeb85761316..0110ca9c8f5 100644 --- a/packages/api-audit-logs/src/types.ts +++ b/packages/api-audit-logs/src/types.ts @@ -2,7 +2,6 @@ import type { GenericRecord } from "@webiny/api/types.js"; import type { Topic } from "@webiny/pubsub/types.js"; import type { MailerContext } from "@webiny/api-mailer/types.js"; import type { IAuditLog } from "~/storage/types.js"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import type { AcoContext } from "@webiny/api-aco/types.js"; import type { IStorageListParams } from "~/storage/abstractions/Storage.js"; import type { Action, App, Entity } from "@webiny/common-audit-logs/types.js"; @@ -80,7 +79,6 @@ export interface AuditLogsContext Pick, Pick, Pick, - Pick, Pick { auditLogs: AuditLogsContextValue; } diff --git a/packages/api-file-manager-ddb/src/index.ts b/packages/api-file-manager-ddb/src/index.ts index 4bcbb750ba1..3aebf63b0ad 100644 --- a/packages/api-file-manager-ddb/src/index.ts +++ b/packages/api-file-manager-ddb/src/index.ts @@ -1,6 +1,6 @@ import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import type { PluginCollection } from "@webiny/plugins/types.js"; -import type { FileManagerStorageOperations } from "@webiny/api-file-manager/types.js"; +import type { FileAliasStorageOperations } from "@webiny/api-file-manager/types.js"; import { AliasesStorageOperations } from "./AliasesStorageOperations.js"; export interface StorageOperationsConfig { @@ -10,8 +10,6 @@ export interface StorageOperationsConfig { export const createFileManagerStorageOperations = ({ documentClient -}: StorageOperationsConfig): FileManagerStorageOperations => { - return { - aliases: new AliasesStorageOperations({ documentClient }) - }; +}: StorageOperationsConfig): FileAliasStorageOperations => { + return new AliasesStorageOperations({ documentClient }); }; diff --git a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts index 94bf947feb0..b46efa2cc04 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/createThreatDetectionEventHandler.ts @@ -1,10 +1,11 @@ import { S3 } from "@webiny/aws-sdk/client-s3/index.js"; import { createEventBridgeEventHandler } from "@webiny/handler-aws"; import { createHandlerOnRequest } from "@webiny/handler"; -import type { GuardDutyEvent, ThreatDetectionContext } from "./types.js"; +import type { GuardDutyEvent } from "./types.js"; import { processThreatScanResult } from "./processThreatScanResult.js"; import { S3AssetMetadataReader } from "~/assetDelivery/s3/S3AssetMetadataReader.js"; import type { EventBridgeEvent } from "@webiny/aws-sdk/types/index.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; const detailType = "GuardDuty Malware Protection Object Scan Result"; @@ -43,7 +44,7 @@ export const createThreatDetectionEventHandler = () => { // Guard Duty event handler. const threatScanEventHandler = createEventBridgeEventHandler( async ({ payload, next, ...rest }) => { - const context = rest.context as ThreatDetectionContext; + const context = rest.context as ApiCoreContext; const threatDetectionEnabled = context.wcp.canUseFileManagerThreatDetection(); diff --git a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts index 6b86cad7162..d2a79dfc60d 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/processThreatScanResult.ts @@ -1,11 +1,12 @@ -import type { GuardDutyEvent, ThreatDetectionContext } from "./types.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; +import type { GuardDutyEvent } from "./types.js"; import { ListFilesUseCase } from "@webiny/api-file-manager/features/file/ListFiles/index.js"; import { UpdateFileUseCase } from "@webiny/api-file-manager/features/file/UpdateFile/index.js"; import { DeleteFileUseCase } from "@webiny/api-file-manager/features/file/DeleteFile/index.js"; import { WebsocketService } from "@webiny/api-websockets/features/WebsocketService/index.js"; export const processThreatScanResult = async ( - context: ThreatDetectionContext, + context: ApiCoreContext, eventDetail: GuardDutyEvent ) => { const websocketService = context.container.resolve(WebsocketService); diff --git a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/types.ts b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/types.ts index 46ded1a8cb4..5cb1dc95eaa 100644 --- a/packages/api-file-manager-s3/src/assetDelivery/threatDetection/types.ts +++ b/packages/api-file-manager-s3/src/assetDelivery/threatDetection/types.ts @@ -1,8 +1,3 @@ -import type { Context as IWebsocketsContext } from "@webiny/api-websockets"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; - -export type ThreatDetectionContext = FileManagerContext & IWebsocketsContext; - export type GuardDutyEvent = { scanResultDetails: { scanResultStatus: diff --git a/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts b/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts index 4eef47f7035..c7f5a505e68 100644 --- a/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts +++ b/packages/api-file-manager-s3/src/flushCdnCache/CdnPathsGenerator.ts @@ -1,4 +1,4 @@ -import type { File } from "@webiny/api-file-manager/types.js"; +import type { File } from "@webiny/api-file-manager/domain/file/types.js"; export class CdnPathsGenerator { generate(file: File) { diff --git a/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts b/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts index 8c34fcbb1b2..ce6dac1bc1b 100644 --- a/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts +++ b/packages/api-file-manager-s3/src/flushCdnCache/InvalidateCacheTask.ts @@ -1,7 +1,6 @@ import { ServiceDiscovery } from "@webiny/api"; import { CloudFront } from "@webiny/aws-sdk/client-cloudfront/index.js"; import type { ITaskRunParams } from "@webiny/tasks/types.js"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import { executeWithRetry } from "@webiny/utils"; import type { ITaskResponseResult } from "@webiny/tasks/response/abstractions/index.js"; @@ -25,7 +24,7 @@ export class InvalidateCloudfrontCacheTask { input, response, isCloseToTimeout - }: ITaskRunParams): Promise { + }: ITaskRunParams): Promise { const manifest = await ServiceDiscovery.load(); if (!manifest) { diff --git a/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts b/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts index d8cce1bf786..9039f99d1bf 100644 --- a/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts +++ b/packages/api-file-manager-s3/src/flushCdnCache/invalidateCacheTaskDefinition.ts @@ -1,9 +1,8 @@ import { createPrivateTaskDefinition } from "@webiny/tasks"; -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import { InvalidateCloudfrontCacheTask } from "./InvalidateCacheTask.js"; export const createInvalidateCacheTask = () => { - return createPrivateTaskDefinition({ + return createPrivateTaskDefinition({ id: "cloudfrontInvalidateCache", title: "Invalidate Cloudfront Cache", description: "A task to invalidate Cloudfront cache by given paths.", diff --git a/packages/api-file-manager-s3/src/utils/getPresignedPostPayload.ts b/packages/api-file-manager-s3/src/utils/getPresignedPostPayload.ts index d83d817b17f..b4f444d261c 100644 --- a/packages/api-file-manager-s3/src/utils/getPresignedPostPayload.ts +++ b/packages/api-file-manager-s3/src/utils/getPresignedPostPayload.ts @@ -1,8 +1,8 @@ import type { PresignedPostOptions } from "@webiny/aws-sdk/client-s3/index.js"; import { S3Client, createPresignedPost } from "@webiny/aws-sdk/client-s3/index.js"; import { validation } from "@webiny/validation"; -import type { FileManagerSettings } from "@webiny/api-file-manager/types.js"; import type { FileData, PresignedPostPayloadDataResponse } from "~/types.js"; +import type { FileManagerSettings } from "@webiny/api-file-manager/domain/settings/types.js"; const S3_BUCKET = process.env.S3_BUCKET; const UPLOAD_MAX_FILE_SIZE_DEFAULT = 1099511627776; // 1TB diff --git a/packages/api-file-manager/src/createFileManager/files.crud.ts b/packages/api-file-manager/src/createFileManager/files.crud.ts deleted file mode 100644 index 84e83e1de9c..00000000000 --- a/packages/api-file-manager/src/createFileManager/files.crud.ts +++ /dev/null @@ -1,358 +0,0 @@ -// @ts-nocheck being removed -import { NotFoundError } from "@webiny/handler-graphql"; -import { createTopic } from "@webiny/pubsub"; -import WebinyError from "@webiny/error"; -import type { - File, - FileManagerFilesStorageOperationsListParamsWhere, - FileManagerFilesStorageOperationsTagsParamsWhere, - FilesCRUD, - FilesListOpts -} from "~/types.js"; -import { ROOT_FOLDER } from "~/contants.js"; -import { getDate } from "@webiny/api-headless-cms/utils/date.js"; -import { getIdentity as utilsGetIdentity } from "@webiny/api-headless-cms/utils/identity.js"; -import type { CmsEntryListSort } from "@webiny/api-headless-cms/types/index.js"; -import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; - -export const createFilesCrud = ( - config: Pick< - FileManagerConfig, - | "storageOperations" - | "filesPermissions" - | "getTenantId" - | "getIdentity" - | "WEBINY_VERSION" - > -): FilesCRUD => { - const { - storageOperations, - filesPermissions, - getTenantId, - getIdentity, - WEBINY_VERSION - } = config; - - return { - onFileBeforeCreate: createTopic("fileManager.onFileBeforeCreate"), - onFileAfterCreate: createTopic("fileManager.onFileAfterCreate"), - onFileBeforeBatchCreate: createTopic("fileManager.onFileBeforeBatchCreate"), - onFileAfterBatchCreate: createTopic("fileManager.onFileAfterBatchCreate"), - onFileBeforeUpdate: createTopic("fileManager.onFileBeforeUpdate"), - onFileAfterUpdate: createTopic("fileManager.onFileAfterUpdate"), - onFileBeforeDelete: createTopic("fileManager.onFileBeforeDelete"), - onFileAfterDelete: createTopic("fileManager.onFileAfterDelete"), - async getFile(id: string) { - await filesPermissions.ensure({ rwd: "r" }); - - const file = await storageOperations.files.get({ - where: { - id, - tenant: getTenantId() - } - }); - - if (!file) { - throw new NotFoundError(`File with id "${id}" does not exists.`); - } - - await filesPermissions.ensure({ owns: file.createdBy }); - - return file; - }, - async createFile(input, meta) { - await filesPermissions.ensure({ rwd: "w" }); - - // Extract ID from file key - const [id] = input.key.split("/"); - - const currentDateTime = new Date(); - const currentIdentity = getIdentity(); - - const file: File = { - ...input, - tags: Array.isArray(input.tags) ? input.tags : [], - aliases: Array.isArray(input.aliases) ? input.aliases : [], - id: input.id || id, - location: { - folderId: input.location?.folderId ?? ROOT_FOLDER - }, - meta: { - private: false, - ...(input.meta || {}) - }, - - createdOn: getDate(input.createdOn, currentDateTime), - modifiedOn: getDate(input.modifiedOn, null), - savedOn: getDate(input.savedOn, currentDateTime), - createdBy: utilsGetIdentity(input.createdBy, currentIdentity)!, - modifiedBy: utilsGetIdentity(input.modifiedBy, null), - savedBy: utilsGetIdentity(input.savedBy, currentIdentity)!, - - tenant: getTenantId(), - webinyVersion: WEBINY_VERSION - }; - - try { - await this.onFileBeforeCreate.publish({ file, meta }); - - const result = await storageOperations.files.create({ file }); - - await this.onFileAfterCreate.publish({ file, meta }); - return result; - } catch (ex) { - // If a `NotAuthorizedError` error was thrown, then we just want to rethrow it. - if (ex instanceof NotAuthorizedError) { - throw ex; - } - - throw new WebinyError( - ex.message || "Could not create a file.", - ex.code || "CREATE_FILE_ERROR", - { - ...(ex.data || {}), - file - } - ); - } - }, - async updateFile(id, input) { - await filesPermissions.ensure({ rwd: "w" }); - - const original = await storageOperations.files.get({ - where: { - id, - tenant: getTenantId() - } - }); - - if (!original) { - throw new NotFoundError(`File with id "${id}" does not exists.`); - } - - await filesPermissions.ensure({ owns: original.createdBy }); - - const currentDateTime = new Date(); - const currentIdentity = getIdentity(); - - const file: File = { - ...original, - ...input, - - createdOn: getDate(input.createdOn, original.createdOn), - modifiedOn: getDate(input.modifiedOn, currentDateTime), - savedOn: getDate(input.savedOn, currentDateTime), - createdBy: utilsGetIdentity(input.createdBy, original.createdBy)!, - modifiedBy: utilsGetIdentity(input.modifiedBy, currentIdentity), - savedBy: utilsGetIdentity(input.savedBy, currentIdentity)!, - - tags: Array.isArray(input.tags) - ? input.tags - : Array.isArray(original.tags) - ? original.tags - : [], - aliases: Array.isArray(input.aliases) - ? input.aliases - : Array.isArray(original.aliases) - ? original.aliases - : [], - id: original.id, - webinyVersion: WEBINY_VERSION - }; - - try { - await this.onFileBeforeUpdate.publish({ - original, - file, - input - }); - - const result = await storageOperations.files.update({ - original, - file - }); - - await this.onFileAfterUpdate.publish({ - original, - file, - input - }); - return result; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not update a file.", - ex.code || "UPDATE_FILE_ERROR", - { - ...(ex.data || {}), - original, - file - } - ); - } - }, - async deleteFile(id) { - await filesPermissions.ensure({ rwd: "d" }); - - const file = await storageOperations.files.get({ - where: { - id, - tenant: getTenantId() - } - }); - - if (!file) { - throw new NotFoundError(`File with id "${id}" does not exists.`); - } - - await filesPermissions.ensure({ owns: file.createdBy }); - - try { - await this.onFileBeforeDelete.publish({ file }); - - await storageOperations.files.delete({ - file - }); - - await this.onFileAfterDelete.publish({ file }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not delete a file.", - ex.code || "DELETE_FILE_ERROR", - { - ...(ex.data || {}), - id, - file - } - ); - } - - return true; - }, - async createFilesInBatch(inputs, meta) { - await filesPermissions.ensure({ rwd: "w" }); - - const tenant = getTenantId(); - - const currentIdentity = getIdentity(); - const currentDateTime = new Date(); - - const files: File[] = inputs.map(input => { - return { - ...input, - tags: Array.isArray(input.tags) ? input.tags : [], - aliases: Array.isArray(input.aliases) ? input.aliases : [], - meta: { - private: false, - ...(input.meta || {}) - }, - location: { - folderId: input.location?.folderId ?? ROOT_FOLDER - }, - - createdOn: getDate(currentDateTime), - modifiedOn: null, - savedOn: getDate(currentDateTime), - createdBy: utilsGetIdentity(currentIdentity)!, - modifiedBy: null, - savedBy: utilsGetIdentity(currentIdentity)!, - - tenant, - webinyVersion: WEBINY_VERSION - }; - }); - - try { - await this.onFileBeforeBatchCreate.publish({ files, meta }); - const results = await storageOperations.files.createBatch({ - files - }); - await this.onFileAfterBatchCreate.publish({ files, meta }); - return results; - } catch (ex) { - throw new WebinyError( - ex.message || "Could not create a batch of files.", - ex.code || "CREATE_FILES_ERROR", - { - ...(ex.data || {}), - files - } - ); - } - }, - async listFiles(params: FilesListOpts = {}) { - await filesPermissions.ensure({ rwd: "r" }); - - const { - limit = 40, - after = null, - where: initialWhere, - sort: initialSort, - search - } = params; - - const where: FileManagerFilesStorageOperationsListParamsWhere = { - ...{ meta: { private_not: true }, ...initialWhere }, - tenant: getTenantId() - }; - - /** - * Always override the createdBy received from the user, if any. - */ - if (await filesPermissions.canAccessOnlyOwnRecords()) { - const identity = getIdentity(); - where.createdBy = identity.id; - } - - const sort: CmsEntryListSort = - Array.isArray(initialSort) && initialSort.length > 0 ? initialSort : ["id_DESC"]; - try { - return await storageOperations.files.list({ - where, - after, - limit, - sort, - search - }); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not list files by given parameters.", - ex.code || "FILE_TAG_SEARCH_ERROR", - { - ...(ex.data || {}), - where, - after, - limit, - sort - } - ); - } - }, - async listTags({ where: initialWhere, after, limit }) { - await filesPermissions.ensure(); - - const where: FileManagerFilesStorageOperationsTagsParamsWhere = { - ...initialWhere, - tenant: getTenantId() - }; - - const params = { - where, - limit: limit || 1000000, - after - }; - - try { - return await storageOperations.files.tags(params); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not search for tags.", - ex.code || "FILE_TAG_SEARCH_ERROR", - { - ...(ex.data || {}), - params - } - ); - } - } - }; -}; diff --git a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts index 43859057502..fabd2eb1ce7 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts @@ -15,6 +15,7 @@ export interface CreateFileInput { type: string; name: string; meta?: Record; + extensions?: Record; tags?: string[]; location?: { folderId: string }; aliases?: string[]; diff --git a/packages/api-file-manager/src/types.ts b/packages/api-file-manager/src/types.ts index 9dcaaaa6122..60f9ae1444a 100644 --- a/packages/api-file-manager/src/types.ts +++ b/packages/api-file-manager/src/types.ts @@ -21,6 +21,7 @@ export interface SettingsPermission extends SecurityPermission { name: "fm.setting"; } +// TODO: implement alias storage export interface FileAliasStorageOperations { storeAliases(file: FileStorageDto): Promise; deleteAliases(file: FileStorageDto): Promise; diff --git a/packages/api-headless-cms-import-export/src/tasks/domain/exportContentAssets/ExportContentAssets.ts b/packages/api-headless-cms-import-export/src/tasks/domain/exportContentAssets/ExportContentAssets.ts index 69c4322dd1b..799763835c4 100644 --- a/packages/api-headless-cms-import-export/src/tasks/domain/exportContentAssets/ExportContentAssets.ts +++ b/packages/api-headless-cms-import-export/src/tasks/domain/exportContentAssets/ExportContentAssets.ts @@ -27,6 +27,7 @@ import { getBucket } from "~/tasks/utils/helpers/getBucket.js"; import { createS3Client } from "~/tasks/utils/helpers/s3Client.js"; import { UniqueResolver } from "~/tasks/utils/uniqueResolver/UniqueResolver.js"; import { WEBINY_EXPORT_ASSETS_EXTENSION } from "~/tasks/constants.js"; +import { ListFilesUseCase } from "@webiny/api-file-manager/features/file/ListFiles/index.js"; export interface ICreateCmsAssetsZipperCallableConfig { filename: string; @@ -119,11 +120,10 @@ export class ExportContentAssets< createEntryAssetsResolver: () => { return new EntryAssetsResolver({ fetchFiles: async params => { - const [items, meta] = await context.fileManager.listFiles(params); - return { - items, - meta - }; + const listFiles = context.container.resolve(ListFilesUseCase); + const listResult = await listFiles.execute(params ?? {}); + + return listResult.value; } }); } diff --git a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessAssets/ImportFromUrlProcessAssets.ts b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessAssets/ImportFromUrlProcessAssets.ts index fa932a61f43..4b1de3d477a 100644 --- a/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessAssets/ImportFromUrlProcessAssets.ts +++ b/packages/api-headless-cms-import-export/src/tasks/domain/importFromUrlProcessAssets/ImportFromUrlProcessAssets.ts @@ -17,6 +17,9 @@ import { getFilePath } from "~/tasks/utils/helpers/getFilePath.js"; import { WebinyError } from "@webiny/error"; import type { ICmsAssetsManifestJson } from "~/tasks/utils/types.js"; import type { IResolvedAsset } from "~/tasks/utils/entryAssets/index.js"; +import { GetFileUseCase } from "@webiny/api-file-manager/features/file/GetFile/index.js"; +import { UpdateFileUseCase } from "@webiny/api-file-manager/features/file/UpdateFile/index.js"; +import { CreateFileUseCase } from "@webiny/api-file-manager/features/file/CreateFile/index.js"; export interface IImportFromUrlProcessAssetsParams { fileFetcher: IFileFetcher; @@ -42,6 +45,9 @@ export class ImportFromUrlProcessAssets< public async run(params: ITaskRunParams): Promise> { const { context, response, input, isCloseToTimeout, isAborted } = params; + const getFile = context.container.resolve(GetFileUseCase); + const createFile = context.container.resolve(CreateFileUseCase); + const updateFile = context.container.resolve(UpdateFileUseCase); const maxInsertErrors = input.maxInsertErrors || 100; @@ -64,8 +70,8 @@ export class ImportFromUrlProcessAssets< const recordExists = async (id: string): Promise => { try { - const result = await context.fileManager.getFile(id); - return !!result; + const result = await getFile.execute(id); + return result.isOk(); } catch { return false; } @@ -173,39 +179,38 @@ export class ImportFromUrlProcessAssets< * Update an existing file record. */ if (exists) { - try { - await context.fileManager.updateFile(record.id, { - ...record - }); - } catch (ex) { + const updateResult = await updateFile.execute(record); + + if (updateResult.isFail()) { result.errors.push({ file: record.key, - message: ex.message + message: updateResult.error.message }); } + continue; } /** * Create a new file record. */ - try { - await context.fileManager.createFile({ - ...record, - id: record.id, - key: record.key, - size: record.size, - type: record.type, - name: record.name, - meta: record.meta, - aliases: record.aliases, - extensions: record.extensions, - location: record.location, - tags: record.tags - }); - } catch (ex) { + const createResult = await createFile.execute({ + ...record, + id: record.id, + key: record.key, + size: record.size, + type: record.type, + name: record.name, + meta: record.meta, + aliases: record.aliases, + extensions: record.extensions, + location: record.location, + tags: record.tags + }); + + if (createResult.isFail()) { result.errors.push({ file: record.key, - message: ex.message + message: createResult.error.message }); } } diff --git a/packages/api-headless-cms-import-export/src/tasks/utils/cmsAssetsZipper/CmsAssetsZipper.ts b/packages/api-headless-cms-import-export/src/tasks/utils/cmsAssetsZipper/CmsAssetsZipper.ts index 3849f3b78b3..779a121b5c4 100644 --- a/packages/api-headless-cms-import-export/src/tasks/utils/cmsAssetsZipper/CmsAssetsZipper.ts +++ b/packages/api-headless-cms-import-export/src/tasks/utils/cmsAssetsZipper/CmsAssetsZipper.ts @@ -94,8 +94,7 @@ export class CmsAssetsZipper implements ICmsAssetsZipper { JSON.stringify({ assets: allLoadedAssets, size: allLoadedAssets.reduce((total, file) => { - const size = parseInt(file.size); - return total + size; + return total + file.size; }, 0) }) ), diff --git a/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/EntryAssetsResolver.ts b/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/EntryAssetsResolver.ts index 799e7ef8788..87b9893d1d6 100644 --- a/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/EntryAssetsResolver.ts +++ b/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/EntryAssetsResolver.ts @@ -1,19 +1,14 @@ -import type { - File, - FileListMeta, - FileListWhereParams, - FilesListOpts -} from "@webiny/api-file-manager/types.js"; +import type { File } from "@webiny/api-file-manager/domain/file/types.js"; import type { IEntryAssetsResolver, IResolvedAsset } from "./abstractions/EntryAssetsResolver.js"; import type { IAsset } from "./abstractions/EntryAssets.js"; export interface IFetchFilesCbResult { items: File[]; - meta: FileListMeta; + meta: Record; } export interface IFetchFilesCb { - (opts?: FilesListOpts): Promise; + (opts?: Record): Promise; } export interface IEntryAssetsResolverParams { @@ -21,28 +16,18 @@ export interface IEntryAssetsResolverParams { } const createResolvedAsset = (file: File): IResolvedAsset => { - const result: IResolvedAsset = { - ...file, - aliases: file.aliases || [] + return { + id: file.id, + key: file.key, + size: file.size, + type: file.type, + name: file.name, + meta: file.meta, + aliases: file.aliases || [], + location: file.location, + tags: file.tags, + extensions: file.extensions }; - /** - * We need to remove unnecessary fields from the resolved assets. - * - * We cannot return specific fields, rather than deleting unnecessary ones, because a user can extend the file model - * so we would not know which fields to return. - */ - delete result.savedBy; - delete result.savedOn; - delete result.modifiedBy; - delete result.modifiedOn; - delete result.accessControl; - delete result.createdBy; - delete result.createdOn; - delete result.tenant; - delete result.locale; - delete result.webinyVersion; - - return result; }; export class EntryAssetsResolver implements IEntryAssetsResolver { @@ -64,7 +49,7 @@ export class EntryAssetsResolver implements IEntryAssetsResolver { } const assets: IResolvedAsset[] = []; - const where: FileListWhereParams = {}; + const where: Record = {}; if (keys.length > 0 && aliases.length > 0) { where.OR = [ { diff --git a/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/abstractions/EntryAssetsResolver.ts b/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/abstractions/EntryAssetsResolver.ts index 025efe5fa39..42173c99eb5 100644 --- a/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/abstractions/EntryAssetsResolver.ts +++ b/packages/api-headless-cms-import-export/src/tasks/utils/entryAssets/abstractions/EntryAssetsResolver.ts @@ -1,7 +1,16 @@ import type { IAsset } from "./EntryAssets.js"; -import type { File } from "@webiny/api-file-manager/types/file.js"; +import type { File } from "@webiny/api-file-manager/domain/file/types.js"; -export type IResolvedAsset = Omit; +export type IResolvedAsset = Omit< + File, + | "savedBy" + | "savedOn" + | "modifiedBy" + | "modifiedOn" + | "accessControl" + | "createdBy" + | "createdOn" +>; export interface IEntryAssetsResolver { resolve(input: IAsset[]): Promise; diff --git a/packages/api-headless-cms-import-export/src/types.ts b/packages/api-headless-cms-import-export/src/types.ts index 8706f9283d9..92bc1650328 100644 --- a/packages/api-headless-cms-import-export/src/types.ts +++ b/packages/api-headless-cms-import-export/src/types.ts @@ -1,4 +1,3 @@ -import type { FileManagerContext } from "@webiny/api-file-manager/types.js"; import type { Context as TasksContext, TaskDataStatus } from "@webiny/tasks/types.js"; import type { ICmsImportExportRecord } from "./domain/abstractions/CmsImportExportRecord.js"; import type { GenericRecord, NonEmptyArray } from "@webiny/api/types.js"; @@ -7,6 +6,7 @@ import type { CmsEntryListWhere, CmsEntryMeta } from "@webiny/api-headless-cms/types/index.js"; +import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; export type * from "./domain/abstractions/CmsImportExportRecord.js"; @@ -164,6 +164,6 @@ export interface CmsImportExportObject { ): Promise; } -export interface Context extends FileManagerContext, TasksContext { +export interface Context extends ApiCoreContext, TasksContext { cmsImportExport: CmsImportExportObject; } diff --git a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts index 05b58ecc06e..d702c54afe7 100644 --- a/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts +++ b/packages/api-headless-cms/src/crud/AccessControl/AccessControl.ts @@ -220,6 +220,24 @@ export class AccessControl { return true; } + async ensureCanAccessModel(params: CanAccessModelParams = {}) { + const canAccess = await this.canAccessModel(params); + if (canAccess) { + return; + } + + if ("model" in params) { + let modelName = "(could not determine name)"; + if (params.model?.name) { + modelName = `"${params.model.name}"`; + } + + throw new NotAuthorizedError(`Not allowed to access content model ${modelName}.`); + } + + throw new NotAuthorizedError(`Not allowed to access content models.`); + } + async canAccessNonOwnedModels(params: GetModelsAccessControlListParams) { const acl = await this.getModelsAccessControlList(params); return acl.some(ace => ace.canAccessNonOwned); diff --git a/packages/tasks/src/types.ts b/packages/tasks/src/types.ts index 3bb649cce71..02cee4a53b5 100644 --- a/packages/tasks/src/types.ts +++ b/packages/tasks/src/types.ts @@ -77,7 +77,7 @@ export enum TaskDataStatus { export interface ITaskIdentity { id: string; - displayName: string | null; + displayName: string; type: string; } From e6888b013f3f15d0823ad087b5f05f0b78e7a7e5 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 1 Dec 2025 14:30:57 +0100 Subject: [PATCH 62/71] fix: add missing tenant checks --- .../enterprise/ApplyThreatScanning/feature.ts | 2 +- packages/api-file-manager-s3/src/index.ts | 26 +++++++++---------- .../api-file-manager/src/graphql/index.ts | 6 +++++ packages/api-file-manager/src/index.ts | 14 ++++++++-- .../ModelToAstConverter/abstractions.ts | 2 +- .../src/graphql/schema/baseSchema.ts | 6 ++++- .../KickOutCurrentUserUseCase.ts | 2 +- packages/api-record-locking/src/index.ts | 14 +++++++--- packages/api-scheduler/src/context.ts | 13 +++++++++- packages/api-websockets/package.json | 5 ++-- .../appTemplates/api/graphql/src/index.ts | 6 +++-- 11 files changed, 68 insertions(+), 28 deletions(-) diff --git a/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts index e99f9459948..f3f610120c5 100644 --- a/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts +++ b/packages/api-file-manager-s3/src/enterprise/ApplyThreatScanning/feature.ts @@ -4,6 +4,6 @@ import { CreateFileWithThreatScanDecorator } from "./CreateFileWithThreatScanDec export const ApplyThreatScanningFeature = createFeature({ name: "FileManagerS3/ApplyThreatScanning", register(container) { - container.register(CreateFileWithThreatScanDecorator); + container.registerDecorator(CreateFileWithThreatScanDecorator); } }); diff --git a/packages/api-file-manager-s3/src/index.ts b/packages/api-file-manager-s3/src/index.ts index 97c1300239e..53d74ca3e09 100644 --- a/packages/api-file-manager-s3/src/index.ts +++ b/packages/api-file-manager-s3/src/index.ts @@ -10,17 +10,17 @@ export { createFileUploadModifier } from "./utils/FileUploadModifier.js"; export { createAssetDelivery } from "./assetDelivery/createAssetDelivery.js"; export { createCustomAssetDelivery } from "./assetDelivery/createCustomAssetDelivery.js"; -export const createFileManagerS3 = () => [ - new ContextPlugin(context => { - FlushCacheFeature.register(context.container); - DeleteFileFromBucketFeature.register(context.container); - WriteFileMetadataFeature.register(context.container); +const contextPlugin = new ContextPlugin(context => { + FlushCacheFeature.register(context.container); + DeleteFileFromBucketFeature.register(context.container); + WriteFileMetadataFeature.register(context.container); - const wcp = context.container.resolve(WcpContext); - if (wcp.canUseFileManagerThreatDetection()) { - ApplyThreatScanningFeature.register(context.container); - } - }), - createS3GraphQLSchema(), - flushCdnCache() -]; + const wcp = context.container.resolve(WcpContext); + if (wcp.canUseFileManagerThreatDetection()) { + ApplyThreatScanningFeature.register(context.container); + } +}); + +contextPlugin.name = `fileManagerS3.context`; + +export const createFileManagerS3 = () => [contextPlugin, createS3GraphQLSchema(), flushCdnCache()]; diff --git a/packages/api-file-manager/src/graphql/index.ts b/packages/api-file-manager/src/graphql/index.ts index 78c4ab8a405..1b46e58fa00 100644 --- a/packages/api-file-manager/src/graphql/index.ts +++ b/packages/api-file-manager/src/graphql/index.ts @@ -8,6 +8,7 @@ import { createBaseSchema } from "~/graphql/baseSchema.js"; import { createFilesSchema } from "~/graphql/filesSchema.js"; import { getFileByUrl } from "~/graphql/getFileByUrl.js"; import { FileModel } from "~/domain/file/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; export const createGraphQLSchemaPlugin = () => { return [ @@ -15,6 +16,11 @@ export const createGraphQLSchemaPlugin = () => { // Files schema is generated dynamically, based on a CMS model, so we need to // register it from a ContextPlugin, to perform additional bootstrap. new ContextPlugin(async context => { + const tenantContext = context.container.resolve(TenantContext); + if (!tenantContext.getTenant()) { + return; + } + const fileModel = context.container.resolve(FileModel); const listModels = context.container.resolve(ListModelsUseCase); diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index 06b17ac4743..e665c5d9987 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -1,7 +1,7 @@ import type { ApiCoreContext } from "@webiny/api-core/types/core.js"; import { ContextPlugin } from "@webiny/api"; import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; -import type { FilePermission, SettingsPermission } from "~/types.js"; +import type { FileAliasStorageOperations, FilePermission, SettingsPermission } from "~/types.js"; import type { AssetDeliveryParams } from "./delivery/setupAssetDelivery.js"; import { setupAssetDelivery } from "./delivery/setupAssetDelivery.js"; import { createGraphQLSchemaPlugin } from "./graphql/index.js"; @@ -13,16 +13,26 @@ import { createFileModel, FILE_MODEL_ID } from "~/domain/file/fileModel.js"; import { WcpContext } from "@webiny/api-core/features/wcp/WcpContext/index.js"; import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; import { FileModel } from "~/domain/file/abstractions.js"; +import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; export * from "./modelModifier/CmsModelModifier.js"; export * from "./delivery/index.js"; -export const createFileManagerContext = () => { +interface FileManagerContextParams { + fileAliasStorageOperations: FileAliasStorageOperations; +} + +export const createFileManagerContext = (params: FileManagerContextParams) => { const plugin = new ContextPlugin(async context => { + const tenantContext = context.container.resolve(TenantContext); const getModel = context.container.resolve(GetModelUseCase); const wcpContext = context.container.resolve(WcpContext); const withPrivateFiles = wcpContext.canUsePrivateFiles(); + if (!tenantContext.getTenant()) { + return; + } + const fileModelDefinition = createFileModel({ withPrivateFiles }); context.plugins.register(fileModelDefinition); diff --git a/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts index 6c4c411d237..9d2cde16f3a 100644 --- a/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/ModelToAstConverter/abstractions.ts @@ -1,5 +1,5 @@ +import { createAbstraction } from "@webiny/feature/api"; import type { CmsModel, CmsModelAst } from "~/types/index.js"; -import { createAbstraction } from "@webiny/feature/createAbstraction.js"; /** * Convert model to AST diff --git a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts index a07db0a60ac..6da206a1c4b 100644 --- a/packages/api-headless-cms/src/graphql/schema/baseSchema.ts +++ b/packages/api-headless-cms/src/graphql/schema/baseSchema.ts @@ -165,7 +165,11 @@ const createSchema = (plugins: PluginsContainer): IGraphQLSchemaPlugin { - return new ContextPlugin(async context => { + const plugin = new ContextPlugin(async context => { context.plugins.register(...createSchema(context.plugins)); }); + + plugin.name = "headless-cms.graphql.createBaseSchema"; + + return plugin; }; diff --git a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts index 1b641feab45..0961b7b6ecb 100644 --- a/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts +++ b/packages/api-record-locking/src/features/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -1,9 +1,9 @@ import { Result } from "@webiny/feature/api"; -import { WebsocketService } from "@webiny/api-websockets/features/WebsocketService"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { parseIdentifier } from "@webiny/utils"; import { KickOutCurrentUserUseCase as UseCaseAbstraction } from "./abstractions.js"; import type { ILockRecord } from "~/domain/index.js"; +import { WebsocketService } from "@webiny/api-websockets/features/WebsocketService/index.js"; class KickOutCurrentUserUseCaseImpl implements UseCaseAbstraction.Interface { constructor( diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index ceeea2c1c5a..4dae01fb559 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -8,6 +8,8 @@ import { getTimeout } from "~/utils/getTimeout.js"; import { RecordLockingFeature } from "~/features/RecordLockingFeature.js"; import { createGraphQLSchema } from "~/graphql/schema.js"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; +import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; export interface ICreateContextPluginParams { /** @@ -18,22 +20,28 @@ export interface ICreateContextPluginParams { const createContextPlugin = (params?: ICreateContextPluginParams) => { const plugin = new ContextPlugin(async context => { + const tenantContext = context.container.resolve(TenantContext); + const identityContext = context.container.resolve(IdentityContext); const wcp = context.container.resolve(WcpContext); const getModel = context.container.resolve(GetModelUseCase); const listModels = context.container.resolve(ListModelsUseCase); - if (!wcp.canUseRecordLocking()) { + if (!wcp.canUseRecordLocking() || !tenantContext.getTenant()) { return; } // Register model plugin - context.plugins.register(createLockingModel()); + const modelDefinition = createLockingModel(); + context.plugins.register(modelDefinition); // Determine timeout value const timeout = getTimeout(params?.timeout); // Fetch CMS model to use for storing record locking data - const recordLockingModel = await getModel.execute(RECORD_LOCKING_MODEL_ID); + const recordLockingModel = await identityContext.withoutAuthorization(() => { + return getModel.execute(RECORD_LOCKING_MODEL_ID); + }); + const publicModels = await listModels.execute({ includePrivate: false }); // Register GraphQL schema plugin diff --git a/packages/api-scheduler/src/context.ts b/packages/api-scheduler/src/context.ts index 6d8722e7e45..e6067f80fd4 100644 --- a/packages/api-scheduler/src/context.ts +++ b/packages/api-scheduler/src/context.ts @@ -12,6 +12,8 @@ import { EventBridgeSchedulerService } from "~/features/SchedulerService/EventBr import { VoidSchedulerService } from "~/features/SchedulerService/VoidSchedulerService.js"; import { createSchedulerModel } from "~/domain/model.js"; import { SchedulerFeature } from "./features/SchedulerFeature.js"; +import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; export interface ICreateHeadlessCmsSchedulerContextParams { getClient(config?: SchedulerClientConfig): Pick; @@ -19,6 +21,13 @@ export interface ICreateHeadlessCmsSchedulerContextParams { export const createSchedulerContext = (params: ICreateHeadlessCmsSchedulerContextParams) => { return new ContextPlugin(async context => { + const tenantContext = context.container.resolve(TenantContext); + const identityContext = context.container.resolve(IdentityContext); + + if (!tenantContext.getTenant()) { + return; + } + const manifest = await getManifest({ client: context.db.driver.getClient() as DynamoDBDocument }); @@ -37,7 +46,9 @@ export const createSchedulerContext = (params: ICreateHeadlessCmsSchedulerContex } context.plugins.register(createSchedulerModel()); - const schedulerModel = await context.cms.getModel(SCHEDULE_MODEL_ID); + const schedulerModel = await identityContext.withoutAuthorization(() => { + return context.cms.getModel(SCHEDULE_MODEL_ID); + }); // Register model via a dedicated abstraction context.container.registerInstance(ScheduledActionModel, schedulerModel); diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json index 13cf1ca9e30..88f5e730a0b 100644 --- a/packages/api-websockets/package.json +++ b/packages/api-websockets/package.json @@ -12,9 +12,8 @@ "Bruno Zorić " ], "exports": { - "./features/WebsocketService": "./src/features/WebsocketService/index.js", - "./*": "./src/*", - ".": "./src/index.js" + "./*": "./*", + ".": "./index.js" }, "license": "MIT", "dependencies": { diff --git a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts index e4124bf5e64..9e9588ac2d5 100644 --- a/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts +++ b/packages/project-aws/_templates/appTemplates/api/graphql/src/index.ts @@ -22,6 +22,7 @@ import { createWebsockets } from "@webiny/api-websockets"; import { createRecordLocking } from "@webiny/api-record-locking"; import { createLogger } from "@webiny/api-log"; import { createHeadlessCmsScheduler } from "@webiny/api-headless-cms-scheduler"; +import { createScheduler } from "@webiny/api-scheduler"; import { createSchedulerClient } from "@webiny/aws-sdk/client-scheduler"; import { createMailerContext, createMailerGraphQL } from "@webiny/api-mailer"; import { createWorkflows } from "@webiny/api-workflows"; @@ -57,7 +58,7 @@ export const handler = createHandler({ createRecordLocking(), createBackgroundTasks(), createFileManagerContext({ - storageOperations: createFileManagerStorageOperations({ documentClient }) + fileAliasStorageOperations: createFileManagerStorageOperations({ documentClient }) }), createFileManagerGraphQL(), createAssetDelivery({ documentClient }), @@ -68,11 +69,12 @@ export const handler = createHandler({ createAuditLogs(), createAcoHcmsContext(), createHcmsTasks(), - createHeadlessCmsScheduler({ + createScheduler({ getClient: config => { return createSchedulerClient(config); } }), + createHeadlessCmsScheduler(), extensions() ], debug From cf6157670dcd5722c4e94adbc0c3e40438aa4fe7 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 1 Dec 2025 17:25:12 +0100 Subject: [PATCH 63/71] fix: remove webinyVersion --- .../__tests__/utils/tenancySecurity.ts | 3 +- .../__tests__/helpers/handlerCore.ts | 3 +- .../__tests__/helpers/tenancySecurity.ts | 3 +- .../AuditLogApiKeyAfterCreateHandler.ts | 3 +- .../AuditLogApiKeyAfterDeleteHandler.ts | 3 +- .../AuditLogApiKeyAfterUpdateHandler.ts | 3 +- .../src/security/definitions/entities.ts | 12 ---- .../__tests__/security/identity.test.ts | 10 ++- .../__tests__/settings/settings.test.ts | 14 ++--- .../features/security/apiKeys/shared/types.ts | 1 - .../groups/CreateGroup/CreateGroupUseCase.ts | 1 - .../teams/CreateTeam/CreateTeamUseCase.ts | 1 - .../shared/TenantLinksRepository.ts | 1 - .../security/tenantLinks/shared/types.ts | 1 - .../DeleteSettings/DeleteSettingsUseCase.ts | 8 +-- .../settings/DeleteSettings/abstractions.ts | 8 +-- .../UpdateSettings/UpdateSettingsUseCase.ts | 8 +-- .../settings/UpdateSettings/abstractions.ts | 8 +-- .../features/settings/UpdateSettings/index.ts | 2 +- .../CreateTenant/CreateTenantUseCase.ts | 1 - .../users/CreateUser/CreateUserUseCase.ts | 1 - .../security/plugins/SecurityRolePlugin.ts | 3 +- .../security/plugins/SecurityTeamPlugin.ts | 3 +- packages/api-core/src/types/security.ts | 9 --- packages/api-core/src/types/tenancy.ts | 1 - packages/api-core/src/types/users.ts | 1 - .../__tests__/helpers/tenancySecurity.ts | 3 +- .../__tests__/utils/tenancySecurity.ts | 3 +- .../__tests__/utils/tenancySecurity.ts | 3 +- .../SettingsInstaller/SettingsInstaller.ts | 15 ++--- .../UpdateSettings/UpdateSettingsUseCase.ts | 6 +- .../__tests__/utils/tenancySecurity.ts | 3 +- .../__tests__/context/tenancySecurity.ts | 3 +- .../__tests__/context/plugins.ts | 3 +- .../__tests__/context/tenancySecurity.ts | 3 +- .../__tests__/filtering/mocks/fields.ts | 3 +- .../__tests__/graphql/security.ts | 3 +- ...msEntryElasticsearchValuesModifier.test.ts | 5 +- .../src/definitions/entry.ts | 3 - .../src/definitions/group.ts | 3 - .../src/definitions/model.ts | 4 -- .../entry/filtering/mocks/fields.ts | 3 +- .../operations/helpers/createModel.ts | 3 +- .../src/definitions/entry.ts | 3 - .../src/definitions/group.ts | 3 - .../src/definitions/model.ts | 4 -- .../__tests__/context/plugins.ts | 3 +- .../__tests__/context/tenancySecurity.ts | 3 +- .../__tests__/helpers/tenancySecurity.ts | 3 +- .../utils/cmsEntryZipper/CmsEntryZipper.ts | 1 - .../mocks/context/tenancySecurity.ts | 3 +- .../__tests__/mocks/targetModel.ts | 1 - .../__tests__/context/plugins.ts | 3 +- .../__tests__/context/tenancySecurity.ts | 3 +- .../__tests__/contentAPI/aco/setup/plugins.ts | 3 +- .../contentAPI/aco/setup/tenancySecurity.ts | 3 +- .../contentAPI/contentEntryMetaField.test.ts | 1 - .../contentAPI/entryPagination.test.ts | 1 - .../mocks/contentModels.noValidation.ts | 3 +- .../contentAPI/mocks/contentModels.ts | 31 +++------- .../mocks/pageWithDynamicZonesModel.ts | 2 - .../contentAPI/pluginsContentModels.test.ts | 6 +- .../contentAPI/republish.entries.test.ts | 3 - .../contentTraverser/mocks/page.entry.ts | 1 - .../fieldIdStorageConverter.test.ts | 8 +-- .../mocks/fieldIdStorageConverter.ts | 2 - .../__tests__/plugins/storage/object/model.ts | 1 - .../__tests__/storageOperations/helpers.ts | 7 +-- .../__tests__/testHelpers/plugins.ts | 3 +- .../__tests__/testHelpers/tenancySecurity.ts | 3 +- packages/api-headless-cms/__tests__/types.ts | 3 +- .../__tests__/validations/models/test.ts | 1 - .../entryDataFactories/createEntryData.ts | 1 - .../createRepublishEntryData.ts | 2 - .../CreateModel/CreateModelRepository.ts | 3 + .../CreateModel/CreateModelUseCase.ts | 1 - .../CreateModelFrom/CreateModelFromUseCase.ts | 3 +- .../UpdateModel/UpdateModelUseCase.ts | 1 - .../contentModel/shared/ModelsFetcher.ts | 16 ++--- .../shared/PluginModelsProvider.ts | 3 +- .../CreateGroup/CreateGroupUseCase.ts | 3 +- .../shared/PluginGroupsProvider.ts | 1 - .../src/plugins/CmsGroupPlugin.ts | 4 +- .../src/plugins/CmsModelPlugin.ts | 2 +- packages/api-headless-cms/src/types/model.ts | 4 -- .../api-headless-cms/src/types/modelGroup.ts | 5 +- packages/api-headless-cms/src/types/types.ts | 5 -- .../converters/valueKeyStorageConverter.ts | 62 ------------------- .../__tests__/context/tenancySecurity.ts | 1 - .../api-mailer/__tests__/graphQLHandler.ts | 3 +- .../__tests__/helpers/tenancySecurity.ts | 3 +- packages/api-record-locking/src/index.ts | 19 +++--- .../mocks/context/tenancySecurity.ts | 3 +- .../__tests__/tenancySecurity.ts | 1 - .../src/context/BaseContext.ts | 14 ++++- .../src/context/pages/PagesStorage.ts | 3 +- .../src/context/pages/pages.types.ts | 1 - .../src/context/redirects/RedirectsStorage.ts | 2 +- .../src/graphql/pages/pages.gql.ts | 6 +- .../__tests__/helpers/tenancySecurity.ts | 3 +- packages/api-workflows/src/context/index.ts | 23 +++++-- packages/api-workflows/src/index.ts | 6 +- .../GetFolderExtensionsFields.test.ts | 3 +- .../app-admin/src/base/createRootContainer.ts | 1 - .../src/types/model.ts | 1 - .../admin/components/FieldEditor/Field.tsx | 30 ++------- .../src/admin/graphql/contentModels.ts | 1 - .../src/admin/plugins/fields/dateTime.tsx | 15 +---- .../src/admin/plugins/fields/ref.tsx | 8 +-- .../src/features/envConfig/abstractions.ts | 1 - .../src/utils/createLocaleEntity.ts | 3 - .../__tests__/helpers/tenancySecurity.ts | 3 +- .../__tests__/helpers/useGraphQLHandler.ts | 3 +- packages/testing/src/context/plugins.ts | 3 +- .../testing/src/context/tenancySecurity.ts | 3 +- 115 files changed, 168 insertions(+), 404 deletions(-) diff --git a/packages/api-aco/__tests__/utils/tenancySecurity.ts b/packages/api-aco/__tests__/utils/tenancySecurity.ts index b632c55166b..95909b5f48a 100644 --- a/packages/api-aco/__tests__/utils/tenancySecurity.ts +++ b/packages/api-aco/__tests__/utils/tenancySecurity.ts @@ -30,8 +30,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config) => { parent: null, tags: [], savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - webinyVersion: "w.w.w" + createdOn: new Date().toISOString() }); context.security.addAuthenticator(async () => { diff --git a/packages/api-audit-logs/__tests__/helpers/handlerCore.ts b/packages/api-audit-logs/__tests__/helpers/handlerCore.ts index 65d2b8deb5c..91f182b1d44 100644 --- a/packages/api-audit-logs/__tests__/helpers/handlerCore.ts +++ b/packages/api-audit-logs/__tests__/helpers/handlerCore.ts @@ -93,8 +93,7 @@ export const createHandlerCore = (params?: CreateHandlerCoreParams) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-audit-logs/__tests__/helpers/tenancySecurity.ts b/packages/api-audit-logs/__tests__/helpers/tenancySecurity.ts index 985c1afaff5..ff0dd03ec22 100644 --- a/packages/api-audit-logs/__tests__/helpers/tenancySecurity.ts +++ b/packages/api-audit-logs/__tests__/helpers/tenancySecurity.ts @@ -17,8 +17,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterCreateHandler.ts b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterCreateHandler.ts index afe8fd5ed7a..fcd1ca28024 100644 --- a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterCreateHandler.ts +++ b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterCreateHandler.ts @@ -18,8 +18,7 @@ const cleanupApiKey = (apiKey: ApiKey): Omit => { description: apiKey.description, name: apiKey.name, permissions: apiKey.permissions, - tenant: apiKey.tenant, - webinyVersion: apiKey.webinyVersion + tenant: apiKey.tenant }; }; diff --git a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterDeleteHandler.ts b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterDeleteHandler.ts index d67023d7005..71da181d25b 100644 --- a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterDeleteHandler.ts +++ b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterDeleteHandler.ts @@ -18,8 +18,7 @@ const cleanupApiKey = (apiKey: ApiKey): Omit => { description: apiKey.description, name: apiKey.name, permissions: apiKey.permissions, - tenant: apiKey.tenant, - webinyVersion: apiKey.webinyVersion + tenant: apiKey.tenant }; }; diff --git a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterUpdateHandler.ts b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterUpdateHandler.ts index f37bf25bc50..e672307909d 100644 --- a/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterUpdateHandler.ts +++ b/packages/api-audit-logs/src/subscriptions/security/handlers/AuditLogApiKeyAfterUpdateHandler.ts @@ -18,8 +18,7 @@ const cleanupApiKey = (apiKey: ApiKey): Omit => { description: apiKey.description, name: apiKey.name, permissions: apiKey.permissions, - tenant: apiKey.tenant, - webinyVersion: apiKey.webinyVersion + tenant: apiKey.tenant }; }; diff --git a/packages/api-core-ddb/src/security/definitions/entities.ts b/packages/api-core-ddb/src/security/definitions/entities.ts index 5306fd57fcc..f793b00b4e0 100644 --- a/packages/api-core-ddb/src/security/definitions/entities.ts +++ b/packages/api-core-ddb/src/security/definitions/entities.ts @@ -64,9 +64,6 @@ export const createGroupEntity = ( permissions: { type: "list" }, - webinyVersion: { - type: "string" - }, ...attributes }); }; @@ -102,9 +99,6 @@ export const createTeamEntity = ( groups: { type: "list" }, - webinyVersion: { - type: "string" - }, ...attributes }); }; @@ -138,9 +132,6 @@ export const createApiKeyEntity = ( permissions: { type: "list" }, - webinyVersion: { - type: "string" - }, ...attributes }); }; @@ -165,9 +156,6 @@ export const createTenantLinkEntity = ( data: { type: "map" }, - webinyVersion: { - type: "string" - }, ...attributes }); }; diff --git a/packages/api-core/__tests__/security/identity.test.ts b/packages/api-core/__tests__/security/identity.test.ts index a678b293f58..a0788412171 100644 --- a/packages/api-core/__tests__/security/identity.test.ts +++ b/packages/api-core/__tests__/security/identity.test.ts @@ -52,8 +52,7 @@ describe("identity test", () => { expect(linksByIdentity[0]).toEqual({ ...link1, - createdOn: expect.any(String), - webinyVersion: process.env.WEBINY_VERSION + createdOn: expect.any(String) }); // List by type @@ -69,8 +68,7 @@ describe("identity test", () => { expect(linksByType[0]).toEqual({ ...link1, - createdOn: expect.any(String), - webinyVersion: process.env.WEBINY_VERSION + createdOn: expect.any(String) }); // List by tenant @@ -84,8 +82,8 @@ describe("identity test", () => { const linksByTenant = linksByTenantResult.value; expect(linksByTenant).toEqual([ - { ...link1, createdOn: expect.any(String), webinyVersion: process.env.WEBINY_VERSION }, - { ...link2, createdOn: expect.any(String), webinyVersion: process.env.WEBINY_VERSION } + { ...link1, createdOn: expect.any(String) }, + { ...link2, createdOn: expect.any(String) } ]); // Update tenant links diff --git a/packages/api-core/__tests__/settings/settings.test.ts b/packages/api-core/__tests__/settings/settings.test.ts index 0be6190db65..0710f4d7ecf 100644 --- a/packages/api-core/__tests__/settings/settings.test.ts +++ b/packages/api-core/__tests__/settings/settings.test.ts @@ -3,8 +3,8 @@ import { Container, createImplementation } from "@webiny/di"; import { SettingsDomain } from "~/domain/settings/feature.js"; import { SettingsFeature } from "~/features/settings/feature.js"; import { GetSettings } from "~/features/settings/GetSettings/index.js"; -import { UpdateSettings } from "~/features/settings/UpdateSettings/index.js"; -import { DeleteSettings } from "~/features/settings/DeleteSettings/index.js"; +import { UpdateSettingsUseCase } from "~/features/settings/UpdateSettings/index.js"; +import { DeleteSettingsUseCase } from "~/features/settings/DeleteSettings/index.js"; import { SettingsStorageOperations } from "~/features/settings/shared/abstractions.js"; import type { SettingsStorageRecord, @@ -174,8 +174,8 @@ describe("Settings Feature", () => { let mockStorage: MockSettingsStorageOperations; let tenantContext: TenantContext.Interface; let getSettings: GetSettings.Interface; - let updateSettings: UpdateSettings.Interface; - let deleteSettings: DeleteSettings.Interface; + let updateSettings: UpdateSettingsUseCase.Interface; + let deleteSettings: DeleteSettingsUseCase.Interface; beforeEach(() => { container = new Container(); @@ -199,8 +199,8 @@ describe("Settings Feature", () => { // Resolve services tenantContext = container.resolve(TenantContext); getSettings = container.resolve(GetSettings); - updateSettings = container.resolve(UpdateSettings); - deleteSettings = container.resolve(DeleteSettings); + updateSettings = container.resolve(UpdateSettingsUseCase); + deleteSettings = container.resolve(DeleteSettingsUseCase); // Set initial tenant tenantContext.setTenant(createTenant({ id: "root", name: "Root Tenant", parent: null })); @@ -469,7 +469,7 @@ describe("Settings Feature", () => { ); const getSettingsWithFailure = failingContainer.resolve(GetSettings); - const updateSettingsWithFailure = failingContainer.resolve(UpdateSettings); + const updateSettingsWithFailure = failingContainer.resolve(UpdateSettingsUseCase); // Test get error const getResult = await getSettingsWithFailure.execute("test"); diff --git a/packages/api-core/src/features/security/apiKeys/shared/types.ts b/packages/api-core/src/features/security/apiKeys/shared/types.ts index f6895438ced..64e464ed725 100644 --- a/packages/api-core/src/features/security/apiKeys/shared/types.ts +++ b/packages/api-core/src/features/security/apiKeys/shared/types.ts @@ -9,7 +9,6 @@ export interface ApiKey { permissions: SecurityPermission[]; createdBy: CreatedBy; createdOn: string; - webinyVersion?: string; } export interface CreateApiKeyInput { diff --git a/packages/api-core/src/features/security/groups/CreateGroup/CreateGroupUseCase.ts b/packages/api-core/src/features/security/groups/CreateGroup/CreateGroupUseCase.ts index 3b81e55560e..a0f627172b0 100644 --- a/packages/api-core/src/features/security/groups/CreateGroup/CreateGroupUseCase.ts +++ b/packages/api-core/src/features/security/groups/CreateGroup/CreateGroupUseCase.ts @@ -55,7 +55,6 @@ class CreateGroupUseCaseImpl implements UseCaseAbstraction.Interface { displayName: identity.displayName, type: identity.type }, - webinyVersion: process.env.WEBINY_VERSION || null, plugin: false }; diff --git a/packages/api-core/src/features/security/teams/CreateTeam/CreateTeamUseCase.ts b/packages/api-core/src/features/security/teams/CreateTeam/CreateTeamUseCase.ts index 4a955e26fda..33805d5e6d7 100644 --- a/packages/api-core/src/features/security/teams/CreateTeam/CreateTeamUseCase.ts +++ b/packages/api-core/src/features/security/teams/CreateTeam/CreateTeamUseCase.ts @@ -55,7 +55,6 @@ export class CreateTeamUseCase implements CreateTeam.Interface { displayName: identity.displayName, type: identity.type }, - webinyVersion: process.env.WEBINY_VERSION || null, plugin: false }; diff --git a/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts b/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts index cb4191643c1..3e28af3df73 100644 --- a/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts +++ b/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts @@ -25,7 +25,6 @@ class TenantLinksRepositoryImpl implements RepositoryAbstraction.Interface { inputs.map(input => ({ ...input, createdOn: new Date().toISOString(), - webinyVersion: process.env.WEBINY_VERSION as string })) ); return Result.ok(); diff --git a/packages/api-core/src/features/security/tenantLinks/shared/types.ts b/packages/api-core/src/features/security/tenantLinks/shared/types.ts index c3f7bef5539..2b78cb9c73f 100644 --- a/packages/api-core/src/features/security/tenantLinks/shared/types.ts +++ b/packages/api-core/src/features/security/tenantLinks/shared/types.ts @@ -41,5 +41,4 @@ export interface TenantLink { tenant: string; type: string; data?: TData; - webinyVersion: string; } diff --git a/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts b/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts index 0a2b5eb6d9d..abb8d7dd440 100644 --- a/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts +++ b/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts @@ -1,17 +1,17 @@ import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { DeleteSettings } from "./abstractions.js"; +import { DeleteSettingsUseCase } from "./abstractions.js"; import { SettingsRepository } from "../shared/abstractions.js"; import { EventPublisher } from "~/features/eventPublisher/abstractions.js"; import { SettingsBeforeDeleteEvent, SettingsAfterDeleteEvent } from "./events.js"; -class DeleteSettingsUseCaseImpl implements DeleteSettings.Interface { +class DeleteSettingsUseCaseImpl implements DeleteSettingsUseCase.Interface { constructor( private repository: SettingsRepository.Interface, private eventPublisher: EventPublisher.Interface ) {} - async execute(name: string): Promise> { + async execute(name: string): Promise> { // Get existing settings first (needed for events) const getResult = await this.repository.get(name); if (getResult.isFail()) { @@ -37,7 +37,7 @@ class DeleteSettingsUseCaseImpl implements DeleteSettings.Interface { } export const DeleteSettingsUseCase = createImplementation({ - abstraction: DeleteSettings, + abstraction: DeleteSettingsUseCase, implementation: DeleteSettingsUseCaseImpl, dependencies: [SettingsRepository, EventPublisher] }); diff --git a/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts b/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts index 726a44a45c0..2849000ae43 100644 --- a/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts +++ b/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts @@ -11,14 +11,14 @@ type DeleteSettingsError = | IDeleteSettingsErrors[keyof IDeleteSettingsErrors] | SettingsRepository.Error; -export interface IDeleteSettings { +export interface IDeleteSettingsUseCase { execute(name: string): Promise>; } -export const DeleteSettings = createAbstraction("DeleteSettings"); +export const DeleteSettingsUseCase = createAbstraction("DeleteSettingsUseCase"); -export namespace DeleteSettings { - export type Interface = IDeleteSettings; +export namespace DeleteSettingsUseCase { + export type Interface = IDeleteSettingsUseCase; export type Error = DeleteSettingsError; } diff --git a/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index 053c0157d28..e98c1cd4705 100644 --- a/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -1,6 +1,6 @@ import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { UpdateSettings } from "./abstractions.js"; +import { UpdateSettingsUseCase } from "./abstractions.js"; import { SettingsRepository } from "../shared/abstractions.js"; import { EventPublisher } from "~/features/eventPublisher/abstractions.js"; import { type ISettings, SettingsModelFactory } from "~/domain/settings/index.js"; @@ -8,14 +8,14 @@ import type { IUpdateSettingsInput } from "../shared/types.js"; import { SettingsValidationError } from "../shared/errors.js"; import { SettingsBeforeUpdateEvent, SettingsAfterUpdateEvent } from "./events.js"; -class UpdateSettingsUseCaseImpl implements UpdateSettings.Interface { +class UpdateSettingsUseCaseImpl implements UpdateSettingsUseCase.Interface { constructor( private repository: SettingsRepository.Interface, private eventPublisher: EventPublisher.Interface, private modelFactory: SettingsModelFactory.Interface ) {} - async execute(input: IUpdateSettingsInput): Promise> { + async execute(input: IUpdateSettingsInput): Promise> { // Validation if (!input.name || input.name.trim().length === 0) { return Result.fail(new SettingsValidationError("Settings name is required")); @@ -63,7 +63,7 @@ class UpdateSettingsUseCaseImpl implements UpdateSettings.Interface { } export const UpdateSettingsUseCase = createImplementation({ - abstraction: UpdateSettings, + abstraction: UpdateSettingsUseCase, implementation: UpdateSettingsUseCaseImpl, dependencies: [SettingsRepository, EventPublisher, SettingsModelFactory] }); diff --git a/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts b/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts index 52aed9b517e..1d25a947b50 100644 --- a/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts +++ b/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts @@ -14,14 +14,14 @@ type UpdateSettingsError = | IUpdateSettingsErrors[keyof IUpdateSettingsErrors] | SettingsRepository.Error; -export interface IUpdateSettings { +export interface IUpdateSettingsUseCase { execute(input: IUpdateSettingsInput): Promise>; } -export const UpdateSettings = createAbstraction("UpdateSettings"); +export const UpdateSettingsUseCase = createAbstraction("UpdateSettingsUseCase"); -export namespace UpdateSettings { - export type Interface = IUpdateSettings; +export namespace UpdateSettingsUseCase { + export type Interface = IUpdateSettingsUseCase; export type Error = UpdateSettingsError; } diff --git a/packages/api-core/src/features/settings/UpdateSettings/index.ts b/packages/api-core/src/features/settings/UpdateSettings/index.ts index b058ce73b05..3db592f34de 100644 --- a/packages/api-core/src/features/settings/UpdateSettings/index.ts +++ b/packages/api-core/src/features/settings/UpdateSettings/index.ts @@ -1,4 +1,4 @@ -export { UpdateSettings } from "./abstractions.js"; +export { UpdateSettingsUseCase } from "./abstractions.js"; export { UpdateSettingsFeature } from "./feature.js"; export type { IUpdateSettingsInput } from "../shared/types.js"; export { diff --git a/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts b/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts index f253e1aa37f..9d8dac61599 100644 --- a/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts +++ b/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts @@ -25,7 +25,6 @@ class CreateTenantUseCaseImpl implements UseCaseAbstraction.Interface { savedOn: new Date().toISOString(), createdOn: new Date().toISOString(), parent: data.parent || null, - webinyVersion: process.env.WEBINY_VERSION }; await this.eventPublisher.publish(new TenantBeforeCreateEvent({ tenant, input: data })); diff --git a/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts b/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts index a411fb38836..0e9a4c0f6f2 100644 --- a/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts +++ b/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts @@ -79,7 +79,6 @@ class CreateUserUseCaseImpl implements UseCaseAbstraction.Interface { createdOn: new Date().toISOString(), createdBy, tenant, - webinyVersion: process.env.WEBINY_VERSION as string }; // 8. Publish before event diff --git a/packages/api-core/src/legacy/security/plugins/SecurityRolePlugin.ts b/packages/api-core/src/legacy/security/plugins/SecurityRolePlugin.ts index 45d1617b877..b27d4e7363c 100644 --- a/packages/api-core/src/legacy/security/plugins/SecurityRolePlugin.ts +++ b/packages/api-core/src/legacy/security/plugins/SecurityRolePlugin.ts @@ -31,8 +31,7 @@ export class SecurityRolePlugin extends Plugin { system: false, plugin: true, createdBy: null, - createdOn: null, - webinyVersion: null + createdOn: null }; } } diff --git a/packages/api-core/src/legacy/security/plugins/SecurityTeamPlugin.ts b/packages/api-core/src/legacy/security/plugins/SecurityTeamPlugin.ts index a600f4b4faf..88461fd4e6a 100644 --- a/packages/api-core/src/legacy/security/plugins/SecurityTeamPlugin.ts +++ b/packages/api-core/src/legacy/security/plugins/SecurityTeamPlugin.ts @@ -31,8 +31,7 @@ export class SecurityTeamPlugin extends Plugin { system: false, plugin: true, createdBy: null, - createdOn: null, - webinyVersion: null + createdOn: null }; } } diff --git a/packages/api-core/src/types/security.ts b/packages/api-core/src/types/security.ts index e6c4e9e579a..f471505ed02 100644 --- a/packages/api-core/src/types/security.ts +++ b/packages/api-core/src/types/security.ts @@ -111,9 +111,6 @@ export interface Group { system: boolean; permissions: SecurityPermission[]; - // Groups defined via plugins don't have `webinyVersion` specified. - webinyVersion: string | null; - // Set to `true` when a group is defined via a plugin. plugin?: boolean; } @@ -160,9 +157,6 @@ export interface Team { system: boolean; groups: string[]; - // Teams defined via plugins don't have `webinyVersion` specified. - webinyVersion: string | null; - // Set to `true` when a group is defined via a plugin. plugin?: boolean; } @@ -239,7 +233,6 @@ export interface TenantLink { tenant: string; type: string; data?: TData; - webinyVersion: string; } export interface PermissionsTenantLinkGroup { @@ -266,7 +259,6 @@ export interface ApiKey { permissions: SecurityPermission[]; createdBy: CreatedBy; createdOn: string; - webinyVersion?: string; } export interface ApiKeyPermission extends SecurityPermission { @@ -339,7 +331,6 @@ export type StorageOperationsDeleteTeamParams = DeleteTeamParams; export interface StorageOperationsCreateTenantLinkParams extends CreateTenantLinkParams { createdOn: string; - webinyVersion: string; } export type StorageOperationsUpdateTenantLinkParams = UpdateTenantLinkParams; diff --git a/packages/api-core/src/types/tenancy.ts b/packages/api-core/src/types/tenancy.ts index 6b61834ab05..c7077b8f4d0 100644 --- a/packages/api-core/src/types/tenancy.ts +++ b/packages/api-core/src/types/tenancy.ts @@ -14,7 +14,6 @@ export interface Tenant { isInstalled: boolean; settings: TenantSettings; parent: string | null; - webinyVersion?: string; createdOn: string; savedOn: string; } diff --git a/packages/api-core/src/types/users.ts b/packages/api-core/src/types/users.ts index c66c33f3227..e41066b7bea 100644 --- a/packages/api-core/src/types/users.ts +++ b/packages/api-core/src/types/users.ts @@ -51,7 +51,6 @@ export interface AdminUser extends BaseUserAttributes { tenant: string; createdOn: string; createdBy: CreatedBy | null | undefined; - webinyVersion: string; } export interface GetUserParams { diff --git a/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts b/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts index 89763eb4c92..6f4da72e3ff 100644 --- a/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts +++ b/packages/api-elasticsearch-tasks/__tests__/helpers/tenancySecurity.ts @@ -23,8 +23,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-file-manager-aco/__tests__/utils/tenancySecurity.ts b/packages/api-file-manager-aco/__tests__/utils/tenancySecurity.ts index fd0b83a29e9..adb3330eb85 100644 --- a/packages/api-file-manager-aco/__tests__/utils/tenancySecurity.ts +++ b/packages/api-file-manager-aco/__tests__/utils/tenancySecurity.ts @@ -24,8 +24,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config) => { parent: null, tags: [], savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - webinyVersion: "w.w.w" + createdOn: new Date().toISOString() }); context.security.addAuthenticator(async () => { diff --git a/packages/api-file-manager/__tests__/utils/tenancySecurity.ts b/packages/api-file-manager/__tests__/utils/tenancySecurity.ts index 2287186cd69..35b8b662195 100644 --- a/packages/api-file-manager/__tests__/utils/tenancySecurity.ts +++ b/packages/api-file-manager/__tests__/utils/tenancySecurity.ts @@ -30,8 +30,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config) => { parent: null, tags: [], savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - webinyVersion: "w.w.w" + createdOn: new Date().toISOString() }); context.security.addAuthenticator(async () => { diff --git a/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts index 546522d3580..3bcbf5f7710 100644 --- a/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts +++ b/packages/api-file-manager/src/features/settings/SettingsInstaller/SettingsInstaller.ts @@ -1,9 +1,9 @@ import { ServiceDiscovery } from "@webiny/api"; import { createImplementation } from "@webiny/feature/api"; import { AppInstaller } from "@webiny/api-core/features/InstallTenant"; -import { UpdateSettings } from "@webiny/api-core/features/UpdateSettings"; -import { DeleteSettings } from "@webiny/api-core/features/DeleteSettings"; +import { DeleteSettingsUseCase } from "@webiny/api-core/features/DeleteSettings"; import { FILE_MANAGER_GENERAL_SETTINGS } from "~/domain/settings/constants.js"; +import { UpdateSettingsUseCase } from "~/features/settings/UpdateSettings/abstractions.js"; class SettingsInstallerImpl implements AppInstaller.Interface { readonly alwaysRun = true; @@ -11,8 +11,8 @@ class SettingsInstallerImpl implements AppInstaller.Interface { readonly dependsOn = []; constructor( - private updateSettings: UpdateSettings.Interface, - private deleteSettings: DeleteSettings.Interface + private updateSettings: UpdateSettingsUseCase.Interface, + private deleteSettings: DeleteSettingsUseCase.Interface ) {} async install(): Promise { @@ -21,10 +21,7 @@ class SettingsInstallerImpl implements AppInstaller.Interface { const { domain } = manifest?.api.cloudfront; await this.updateSettings.execute({ - name: FILE_MANAGER_GENERAL_SETTINGS, - data: { - srcPrefix: `${domain}/files` - } + srcPrefix: `${domain}/files` }); } @@ -36,5 +33,5 @@ class SettingsInstallerImpl implements AppInstaller.Interface { export const SettingsInstaller = createImplementation({ abstraction: AppInstaller, implementation: SettingsInstallerImpl, - dependencies: [UpdateSettings, DeleteSettings] + dependencies: [UpdateSettingsUseCase, DeleteSettingsUseCase] }); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index c8c73469d79..f811197bd25 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -1,6 +1,6 @@ import { Result } from "@webiny/feature/api"; import { UpdateSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; -import { UpdateSettings } from "@webiny/api-core/features/settings/UpdateSettings"; +import { UpdateSettingsUseCase } from "@webiny/api-core/features/settings/UpdateSettings"; import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; import type { FileManagerSettings } from "~/domain/settings/types.js"; import type { UpdateSettingsInput } from "~/domain/settings/types.js"; @@ -14,7 +14,7 @@ import { createZodError } from "@webiny/utils"; class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { constructor( - private updateSettings: UpdateSettings.Interface, + private updateSettings: UpdateSettingsUseCase.Interface, private getSettings: GetSettingsUseCase.Interface, private eventPublisher: EventPublisher.Interface ) {} @@ -76,5 +76,5 @@ class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { export const UpdateSettingsUseCase = UseCaseAbstraction.createImplementation({ implementation: UpdateSettingsUseCaseImpl, - dependencies: [UpdateSettings, GetSettingsUseCase, EventPublisher] + dependencies: [UpdateSettingsUseCase, GetSettingsUseCase, EventPublisher] }); diff --git a/packages/api-headless-cms-aco/__tests__/utils/tenancySecurity.ts b/packages/api-headless-cms-aco/__tests__/utils/tenancySecurity.ts index 83b88a7b62d..8a5a6c753a7 100644 --- a/packages/api-headless-cms-aco/__tests__/utils/tenancySecurity.ts +++ b/packages/api-headless-cms-aco/__tests__/utils/tenancySecurity.ts @@ -24,8 +24,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config) => { parent: null, tags: [], savedOn: new Date().toISOString(), - createdOn: new Date().toISOString(), - webinyVersion: "w.w.w" + createdOn: new Date().toISOString() }); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts b/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts index 46d66f4ce01..a0f494c6e26 100644 --- a/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts +++ b/packages/api-headless-cms-bulk-actions/__tests__/context/tenancySecurity.ts @@ -22,8 +22,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-ddb-es/__tests__/context/plugins.ts b/packages/api-headless-cms-ddb-es/__tests__/context/plugins.ts index 25cf15c9e25..3fdcc12b4b3 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/context/plugins.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/context/plugins.ts @@ -83,8 +83,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-headless-cms-ddb-es/__tests__/context/tenancySecurity.ts b/packages/api-headless-cms-ddb-es/__tests__/context/tenancySecurity.ts index bf82de82d4d..5b589cd31e9 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/context/tenancySecurity.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/context/tenancySecurity.ts @@ -78,8 +78,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-ddb-es/__tests__/filtering/mocks/fields.ts b/packages/api-headless-cms-ddb-es/__tests__/filtering/mocks/fields.ts index 595de4c8629..05d2a5014c9 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/filtering/mocks/fields.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/filtering/mocks/fields.ts @@ -67,8 +67,7 @@ export const createModel = (): CmsModel => { id: "group", name: "Group" }, - titleFieldId: "title", - webinyVersion: "x.x.x" + titleFieldId: "title" }; }; diff --git a/packages/api-headless-cms-ddb-es/__tests__/graphql/security.ts b/packages/api-headless-cms-ddb-es/__tests__/graphql/security.ts index 738a98bedf8..f1e13c728fd 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/graphql/security.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/graphql/security.ts @@ -10,8 +10,7 @@ export const createSecurity = () => { new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-ddb-es/__tests__/plugins/CmsEntryElasticsearchValuesModifier.test.ts b/packages/api-headless-cms-ddb-es/__tests__/plugins/CmsEntryElasticsearchValuesModifier.test.ts index 657c9c4919f..cec11272884 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/plugins/CmsEntryElasticsearchValuesModifier.test.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/plugins/CmsEntryElasticsearchValuesModifier.test.ts @@ -21,8 +21,7 @@ const mockModel: CmsModel = { group: { id: "group", name: "group" - }, - webinyVersion: "0.0.0" + } }; const createdBy: CmsIdentity = { id: "a", @@ -42,9 +41,7 @@ const mockEntry: CmsEntry = { }, status: "draft", locked: false, - locale: "en-US", tenant: "root", - webinyVersion: "0.0.0", createdBy, ownedBy: createdBy, meta: {}, diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts index 1dc8641561a..d8643eedb0c 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/entry.ts @@ -27,9 +27,6 @@ export const createEntryEntity = (params: CreateEntryEntityParams): Entity __type: { type: "string" }, - webinyVersion: { - type: "string" - }, tenant: { type: "string" }, diff --git a/packages/api-headless-cms-ddb-es/src/definitions/group.ts b/packages/api-headless-cms-ddb-es/src/definitions/group.ts index c477586c359..fbea790322b 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/group.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/group.ts @@ -22,9 +22,6 @@ export const createGroupEntity = (params: CreateGroupEntityParams): Entity TYPE: { type: "string" }, - webinyVersion: { - type: "string" - }, id: { type: "string" }, diff --git a/packages/api-headless-cms-ddb-es/src/definitions/model.ts b/packages/api-headless-cms-ddb-es/src/definitions/model.ts index 25271918381..cbb872d0c2a 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/model.ts @@ -24,10 +24,6 @@ export const createModelEntity = (params: CreateModelEntityParams): Entity type: "string", required: true }, - webinyVersion: { - type: "string", - required: true - }, name: { type: "string", required: true diff --git a/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/mocks/fields.ts b/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/mocks/fields.ts index e852bbae8a5..19355400185 100644 --- a/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/mocks/fields.ts +++ b/packages/api-headless-cms-ddb/__tests__/operations/entry/filtering/mocks/fields.ts @@ -58,8 +58,7 @@ export const createModel = (): CmsModel => { id: "group", name: "Group" }, - titleFieldId: "title", - webinyVersion: "x.x.x" + titleFieldId: "title" }; }; diff --git a/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts b/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts index 43289296221..2389fc24565 100644 --- a/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts +++ b/packages/api-headless-cms-ddb/__tests__/operations/helpers/createModel.ts @@ -194,7 +194,6 @@ export const createModel = (): CmsModel => { label: "Settings" } ], - tenant: "root", - webinyVersion: "x.x.x" + tenant: "root" }; }; diff --git a/packages/api-headless-cms-ddb/src/definitions/entry.ts b/packages/api-headless-cms-ddb/src/definitions/entry.ts index 64d821ceff7..cf6e7627888 100644 --- a/packages/api-headless-cms-ddb/src/definitions/entry.ts +++ b/packages/api-headless-cms-ddb/src/definitions/entry.ts @@ -34,9 +34,6 @@ export const createEntryEntity = (params: Params): Entity => { __type: { type: "string" }, - webinyVersion: { - type: "string" - }, tenant: { type: "string" }, diff --git a/packages/api-headless-cms-ddb/src/definitions/group.ts b/packages/api-headless-cms-ddb/src/definitions/group.ts index 09734d38aef..2866a1c73dc 100644 --- a/packages/api-headless-cms-ddb/src/definitions/group.ts +++ b/packages/api-headless-cms-ddb/src/definitions/group.ts @@ -22,9 +22,6 @@ export const createGroupEntity = (params: Params): Entity => { TYPE: { type: "string" }, - webinyVersion: { - type: "string" - }, id: { type: "string" }, diff --git a/packages/api-headless-cms-ddb/src/definitions/model.ts b/packages/api-headless-cms-ddb/src/definitions/model.ts index 3a33aba9697..cc391bf2d45 100644 --- a/packages/api-headless-cms-ddb/src/definitions/model.ts +++ b/packages/api-headless-cms-ddb/src/definitions/model.ts @@ -24,10 +24,6 @@ export const createModelEntity = (params: Params): Entity => { type: "string", required: true }, - webinyVersion: { - type: "string", - required: true - }, name: { type: "string", required: true diff --git a/packages/api-headless-cms-es-tasks/__tests__/context/plugins.ts b/packages/api-headless-cms-es-tasks/__tests__/context/plugins.ts index 1833224c14a..ad41e98835a 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/context/plugins.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/context/plugins.ts @@ -85,8 +85,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams = {}) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-headless-cms-es-tasks/__tests__/context/tenancySecurity.ts b/packages/api-headless-cms-es-tasks/__tests__/context/tenancySecurity.ts index 0c381fb23ad..fa7e59d7ad0 100644 --- a/packages/api-headless-cms-es-tasks/__tests__/context/tenancySecurity.ts +++ b/packages/api-headless-cms-es-tasks/__tests__/context/tenancySecurity.ts @@ -56,8 +56,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-import-export/__tests__/helpers/tenancySecurity.ts b/packages/api-headless-cms-import-export/__tests__/helpers/tenancySecurity.ts index 0393e30a88c..74ea6d259c2 100644 --- a/packages/api-headless-cms-import-export/__tests__/helpers/tenancySecurity.ts +++ b/packages/api-headless-cms-import-export/__tests__/helpers/tenancySecurity.ts @@ -23,8 +23,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts b/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts index 0878c4536d6..7e24bb647a6 100644 --- a/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts +++ b/packages/api-headless-cms-import-export/src/tasks/utils/cmsEntryZipper/CmsEntryZipper.ts @@ -37,7 +37,6 @@ const createBufferData = (params: ICmsEntryEntriesJson) => { */ delete item.tenant; delete item.locked; - delete item.webinyVersion; delete item.version; delete item.entryId; delete item.modelId; diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts index 5ce2b90a789..08f23576190 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/context/tenancySecurity.ts @@ -55,8 +55,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts b/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts index 02b3c36016a..64ff562a0df 100644 --- a/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts +++ b/packages/api-headless-cms-scheduler/__tests__/mocks/targetModel.ts @@ -28,7 +28,6 @@ export const createMockTargetModel = (): CmsModel => { layout: [["title"]], createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - webinyVersion: "0.0.0", tenant: "root", titleFieldId: "title" }; diff --git a/packages/api-headless-cms-tasks/__tests__/context/plugins.ts b/packages/api-headless-cms-tasks/__tests__/context/plugins.ts index 65ca30d5aef..17f0f934b26 100644 --- a/packages/api-headless-cms-tasks/__tests__/context/plugins.ts +++ b/packages/api-headless-cms-tasks/__tests__/context/plugins.ts @@ -89,8 +89,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams = {}) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts b/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts index 02a63868dc4..aa9aafe0d93 100644 --- a/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts +++ b/packages/api-headless-cms-tasks/__tests__/context/tenancySecurity.ts @@ -56,8 +56,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/plugins.ts b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/plugins.ts index f2676a32f10..73e29af6b61 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/plugins.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/plugins.ts @@ -83,8 +83,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/tenancySecurity.ts b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/tenancySecurity.ts index 209c284535f..18235250518 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/aco/setup/tenancySecurity.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/aco/setup/tenancySecurity.ts @@ -17,8 +17,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts index 842993d1928..cd99c8721fd 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/contentEntryMetaField.test.ts @@ -115,7 +115,6 @@ describe("Content Entry Meta Field", () => { slug: "test-category" }, status: "draft", - webinyVersion: "5.27.0", meta: createMetaData() }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts index 0de2b9d5231..dd6aeafed31 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/entryPagination.test.ts @@ -17,7 +17,6 @@ const createFruitData = (counter: number) => { id, entryId, version: counter, - webinyVersion: "version", modelId: "fruit", createdBy: { id: "admin", diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts index c4a08797d77..fb6f436dec1 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.noValidation.ts @@ -64,8 +64,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion: "0.0.0" + tenant: "root" } ]; diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts index 1a77b881398..7d0722920cc 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/contentModels.ts @@ -4,7 +4,6 @@ import type { CmsGroupPlugin, CmsModelInput, CmsModelPlugin } from "~/plugins"; import { createCmsGroupPlugin, createCmsModelPlugin } from "~/plugins"; const contentModelGroup = createContentModelGroup(); -const webinyVersion = "0.0.0"; export interface Fruit { id?: string; @@ -164,8 +163,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion: "0.0.0" + tenant: "root" }, // category @@ -240,8 +238,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion: "0.0.0" + tenant: "root" }, // category { @@ -341,8 +338,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion: "0.0.0" + tenant: "root" }, // product { @@ -944,8 +940,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion: "0.0.0" + tenant: "root" }, // product review { @@ -1051,8 +1046,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion + tenant: "root" }, // author { @@ -1095,8 +1089,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion + tenant: "root" }, // fruit { @@ -1601,8 +1594,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion + tenant: "root" }, // bug { @@ -1739,8 +1731,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion + tenant: "root" }, // article { @@ -1841,8 +1832,7 @@ const models: CmsModel[] = [ } } ], - tenant: "root", - webinyVersion + tenant: "root" }, // Wrap /** @@ -1904,8 +1894,7 @@ const models: CmsModel[] = [ layout: [], tenant: "root", titleFieldId: "title", - description: "Wrapper model for ref field with multiple models", - webinyVersion + description: "Wrapper model for ref field with multiple models" } ]; diff --git a/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts b/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts index eec0466dcb1..578678bb46c 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/mocks/pageWithDynamicZonesModel.ts @@ -1,4 +1,3 @@ -import { version as webinyVersion } from "@webiny/api/package.json"; import type { CmsModel as BaseCmsModel, CmsModelField as BaseCmsModelField, @@ -19,7 +18,6 @@ interface CmsModel extends Omit { export const pageModel: CmsModel = { tenant: "root", - webinyVersion, name: "Page", group: { id: "62f39c13ebe1d800091bf33c", diff --git a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts index cb0ff85e753..c214c4ff69d 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/pluginsContentModels.test.ts @@ -146,16 +146,14 @@ describe("content model plugins", () => { beforeEach(async () => { await storageOperations.models.delete({ model: { - ...(contentModelPlugin.contentModel as CmsModel), - webinyVersion: "x.x.x" + ...(contentModelPlugin.contentModel as CmsModel) } }); }); afterEach(async () => { await storageOperations.models.delete({ model: { - ...(contentModelPlugin.contentModel as CmsModel), - webinyVersion: "x.x.x" + ...(contentModelPlugin.contentModel as CmsModel) } }); }); diff --git a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts index 8a1d893d590..3fe635fb15e 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/republish.entries.test.ts @@ -8,8 +8,6 @@ import { useCategoryReadHandler } from "../testHelpers/useCategoryReadHandler"; import { useProductManageHandler } from "../testHelpers/useProductManageHandler"; import { createStorageOperationsContext } from "~tests/storageOperations/context"; -const webinyVersion = "0.0.0"; - interface CreateEntryResult { entry: CmsEntry; input: Record; @@ -145,7 +143,6 @@ describe("Republish entries", () => { id: `${id}#0001`, entryId: id, tenant: model.tenant, - webinyVersion, locked: false, values: input, createdOn: date.toISOString(), diff --git a/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts b/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts index 9af877aabb7..2cb8c0f6474 100644 --- a/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts +++ b/packages/api-headless-cms/__tests__/contentTraverser/mocks/page.entry.ts @@ -303,7 +303,6 @@ export const pageEntry = { createdOn: "2024-06-27T12:45:56.795Z", modifiedOn: "2024-07-05T09:18:51.860Z", locked: false, - webinyVersion: "5.40.0", modifiedBy: { type: "admin", displayName: "John Doe", diff --git a/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts b/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts index a76e702cbc3..1116585aac9 100644 --- a/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts +++ b/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts @@ -97,9 +97,7 @@ describe("field id storage converter", () => { * Conversion feature is not enabled. */ it("should not convert field value paths to storage ones", () => { - const model = createModel({ - webinyVersion: "disable" - }); + const model = createModel(); const entry = createRawEntry(); /** @@ -131,9 +129,7 @@ describe("field id storage converter", () => { expect(result).toEqual(createRawEntry().values); }); it("should not convert field value paths from storage ones", () => { - const model = createModel({ - webinyVersion: "disable" - }); + const model = createModel(); const entry = createStoredEntry(); /** diff --git a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts index 6846159e3b6..ce16802c9ea 100644 --- a/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts +++ b/packages/api-headless-cms/__tests__/converters/mocks/fieldIdStorageConverter.ts @@ -595,7 +595,6 @@ export const createModel = (base?: Partial>) layout: fields.map(field => { return [field.id]; }), - webinyVersion: "5.50.0", tenant: "root", ...(base || {}), fields @@ -920,7 +919,6 @@ const createBaseEntry = (values: Record): CmsEntry => { locked: false, status: "draft", version: 1, - webinyVersion: "w.w.w", values }; }; diff --git a/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts b/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts index f2483d70fd4..cc76e7e330c 100644 --- a/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts +++ b/packages/api-headless-cms/__tests__/plugins/storage/object/model.ts @@ -15,7 +15,6 @@ export const createObjectMockModel = (): CmsModel => { displayName: "admin", id: "admin" }, - webinyVersion: "w.w.w", group: { name: "Group", diff --git a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts index 7f36e6bb0ca..cb38601a0a1 100644 --- a/packages/api-headless-cms/__tests__/storageOperations/helpers.ts +++ b/packages/api-headless-cms/__tests__/storageOperations/helpers.ts @@ -10,8 +10,6 @@ import { createIdentifier, generateAlphaNumericLowerCaseId, mdbid } from "@webin import crypto from "crypto"; import type { PluginsContainer } from "@webiny/plugins"; -const webinyVersion = "0.0.0"; - const baseGroup = new CmsGroupPlugin({ name: "Base group", tenant: "root", @@ -90,8 +88,7 @@ export const createPersonModel = (): CmsModel => { layout: Object.values(personModelFields).map(field => { return [field.id]; }), - description: "", - webinyVersion + description: "" }; }; @@ -144,7 +141,6 @@ export const createPersonEntries = async ( savedOn: new Date().toISOString(), modelId: personModel.modelId, tenant: personModel.tenant, - webinyVersion: personModel.webinyVersion, locked: false, status: "draft", values: { @@ -179,7 +175,6 @@ export const createPersonEntries = async ( savedOn: new Date().toISOString(), modelId: personModel.modelId, tenant: personModel.tenant, - webinyVersion: personModel.webinyVersion, locked: false, status: "draft", values: { diff --git a/packages/api-headless-cms/__tests__/testHelpers/plugins.ts b/packages/api-headless-cms/__tests__/testHelpers/plugins.ts index 743a6698744..0419f5a4e2c 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/plugins.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/plugins.ts @@ -85,8 +85,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/api-headless-cms/__tests__/testHelpers/tenancySecurity.ts b/packages/api-headless-cms/__tests__/testHelpers/tenancySecurity.ts index e63157bcb5e..2ee1559cdf4 100644 --- a/packages/api-headless-cms/__tests__/testHelpers/tenancySecurity.ts +++ b/packages/api-headless-cms/__tests__/testHelpers/tenancySecurity.ts @@ -23,8 +23,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-headless-cms/__tests__/types.ts b/packages/api-headless-cms/__tests__/types.ts index 5e81996f181..75684aaa210 100644 --- a/packages/api-headless-cms/__tests__/types.ts +++ b/packages/api-headless-cms/__tests__/types.ts @@ -6,13 +6,12 @@ export type CmsModel = Omit< BaseCmsModel, | "locale" | "tenant" - | "webinyVersion" | "createdOn" | "createdBy" | "savedOn" | "isPrivate" >; -export type CmsGroup = Omit; +export type CmsGroup = Omit; /** * Managers / Readers */ diff --git a/packages/api-headless-cms/__tests__/validations/models/test.ts b/packages/api-headless-cms/__tests__/validations/models/test.ts index c5213de5e12..e2c1fc858da 100644 --- a/packages/api-headless-cms/__tests__/validations/models/test.ts +++ b/packages/api-headless-cms/__tests__/validations/models/test.ts @@ -15,7 +15,6 @@ export const createTestModel = (model: Partial = {}): CmsModel => { id: "group", name: "Group" }, - webinyVersion: "x.x.x", ...model }; }; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts index 992b7a17eb7..cc870b0ca85 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts @@ -128,7 +128,6 @@ export const createEntryData = async ({ } const entry: CmsEntry = { - webinyVersion: context.WEBINY_VERSION, tenant: getTenant().id, entryId, id, diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts index b9ac402bc63..67fedba8c44 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createRepublishEntryData.ts @@ -59,8 +59,6 @@ export const createRepublishEntryData = async ({ ), revisionLastPublishedOn: getDate(currentDateTime), revisionLastPublishedBy: getIdentity(currentIdentity), - - webinyVersion: context.WEBINY_VERSION, values }; diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts index 77ecdc7839d..a16df76b074 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelRepository.ts @@ -155,6 +155,8 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { // TODO: ideally, this will eventually be handled by the Model domain object model.tags = ensureTypeTag(model); + console.log("create model", model); + // Persist to storage await this.storageOperations.models.create({ model }); @@ -163,6 +165,7 @@ class CreateModelRepositoryImpl implements RepositoryAbstraction.Interface { return Result.ok(); } catch (error) { + console.error(error, error.stack); return Result.fail(new ModelPersistenceError(error as Error)); } } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts index 4bef02c2d7e..bd827974f74 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/CreateModelUseCase.ts @@ -102,7 +102,6 @@ class CreateModelUseCaseImpl implements UseCaseAbstraction.Interface { displayName: identity.displayName, type: identity.type }, - webinyVersion: this.cmsContext.WEBINY_VERSION, description: data.description || "", group: { id: group.id, diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts index e04ccf5f756..ede8398bc8f 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/CreateModelFromUseCase.ts @@ -117,8 +117,7 @@ class CreateModelFromUseCaseImpl implements UseCaseAbstraction.Interface { }, createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - tenant: original.tenant || tenant.id, - webinyVersion: this.cmsContext.WEBINY_VERSION + tenant: original.tenant || tenant.id }; // Access control check on the new model diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts index a035b45f2c5..de5a8d03f2b 100644 --- a/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/UpdateModelUseCase.ts @@ -126,7 +126,6 @@ class UpdateModelUseCaseImpl implements UseCaseAbstraction.Interface { group, description: data.description || original.description, tenant: original.tenant || tenant.id, - webinyVersion: this.cmsContext.WEBINY_VERSION, savedOn: new Date().toISOString() }; diff --git a/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts index 4f7ad2a8921..75888c0fa11 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/ModelsFetcher.ts @@ -37,12 +37,15 @@ class ModelsFetcherImpl implements FetcherAbstraction.Interface { tenant: tenant.id }); + // Fetch plugin models (with caching and access control) + const pluginModels = await this.pluginModelsProvider.list(tenant.id); + // Try to get from cache first const cached = await this.modelCache.getOrSet(cacheKey, async () => { return this.fetchAndMergeModels(tenant.id); }); - return Result.ok(cached); + return Result.ok([...cached, ...pluginModels]); } catch (error) { return Result.fail(new ModelPersistenceError(error as Error)); } @@ -63,23 +66,20 @@ class ModelsFetcherImpl implements FetcherAbstraction.Interface { } private async fetchAndMergeModels(tenant: string): Promise { - // 1. Fetch plugin models (with caching and access control) - const pluginModels = await this.pluginModelsProvider.list(tenant); - - // 2. Fetch database models (with caching) + // 1. Fetch database models (with caching) const dbCacheKey = createCacheKey({ tenant, id: "storage" }); const databaseModels = await this.modelCache.getOrSet(dbCacheKey, async () => { return this.storageOperations.models.list({ where: { tenant } }); }); - // 3. Ensure type tags on database models + // 2. Ensure type tags on database models const taggedDatabaseModels = databaseModels.map(model => { model.tags = ensureTypeTag(model); return model; }); - // 4. Return merged models. - return [...taggedDatabaseModels, ...pluginModels]; + // 3. Return merged models. + return taggedDatabaseModels; } } diff --git a/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts b/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts index d441ff825f3..477dbdf9407 100644 --- a/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts +++ b/packages/api-headless-cms/src/features/contentModel/shared/PluginModelsProvider.ts @@ -31,8 +31,7 @@ class PluginModelsProviderImpl implements ProviderAbstraction.Interface { return { ...plugin.contentModel, tags: ensureTypeTag(plugin.contentModel), - tenant, - webinyVersion: this.cmsContext.WEBINY_VERSION + tenant }; }) as unknown as CmsModel[]; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts index 9399fff12b0..198083c0815 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupUseCase.ts @@ -73,8 +73,7 @@ class CreateGroupUseCaseImpl implements UseCaseAbstraction.Interface { id: identity.id, displayName: identity.displayName, type: identity.type - }, - webinyVersion: this.cmsContext.WEBINY_VERSION + } }; // Access control check on created group diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts index 8071a5b8f6e..0dead3f89af 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts @@ -58,7 +58,6 @@ class PluginGroupsProviderImpl implements ProviderAbstraction.Interface { return { ...plugin.contentModelGroup, tenant: tenant.id, - webinyVersion: this.cmsContext.WEBINY_VERSION }; }); diff --git a/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts index 758dcfc027b..c6debc5521e 100644 --- a/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts +++ b/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts @@ -2,12 +2,12 @@ import { Plugin } from "@webiny/plugins"; import type { CmsGroup as BaseCmsGroup } from "~/types/index.js"; export interface CmsGroupInput - extends Omit { + extends Omit { tenant?: string; locale?: string; } -export interface CmsGroup extends Omit { +export interface CmsGroup extends Omit { tenant?: string; locale?: string; } diff --git a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts index 4b4bf52bcc4..ee61bb00477 100644 --- a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts +++ b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts @@ -87,7 +87,7 @@ export interface CmsPrivateModelFull export type CmsModelInput = CmsApiModel | CmsPrivateModel | CmsApiModelFull | CmsPrivateModelFull; export interface CmsModelPluginModel - extends Omit { + extends Omit { locale?: string; tenant?: string; } diff --git a/packages/api-headless-cms/src/types/model.ts b/packages/api-headless-cms/src/types/model.ts index 6ee8586c19e..ea5931c35de 100644 --- a/packages/api-headless-cms/src/types/model.ts +++ b/packages/api-headless-cms/src/types/model.ts @@ -97,10 +97,6 @@ export interface CmsModel { * If not set by the user, the system will try to assign a `file` field which has `imagesOnly` enabled. */ imageFieldId?: string | null; - /** - * The version of Webiny which this record was stored with. - */ - webinyVersion: string; /** * Is model private? diff --git a/packages/api-headless-cms/src/types/modelGroup.ts b/packages/api-headless-cms/src/types/modelGroup.ts index 0e34978a25c..43ee7e88570 100644 --- a/packages/api-headless-cms/src/types/modelGroup.ts +++ b/packages/api-headless-cms/src/types/modelGroup.ts @@ -58,10 +58,7 @@ export interface CmsGroup { * Date group was created or changed on. */ savedOn?: string; - /** - * Which Webiny version was this record stored with. - */ - webinyVersion: string; + /** * Is group private? * This is meant to be used for some internal groups - will not be visible in the schema. diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index afad0e557f0..86ecfddf083 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -410,11 +410,6 @@ export interface ICmsEntryState { * @category CmsEntry */ export interface CmsEntry { - /** - * A version of the webiny this entry was created with. - * This can be used when upgrading the system, so we know which entries to update. - */ - webinyVersion: string; /** * Tenant id which is this entry for. Can be used in case of shared storage. */ diff --git a/packages/api-headless-cms/src/utils/converters/valueKeyStorageConverter.ts b/packages/api-headless-cms/src/utils/converters/valueKeyStorageConverter.ts index c19edbbd1c5..8aac47e9013 100644 --- a/packages/api-headless-cms/src/utils/converters/valueKeyStorageConverter.ts +++ b/packages/api-headless-cms/src/utils/converters/valueKeyStorageConverter.ts @@ -5,56 +5,6 @@ import type { } from "~/utils/converters/ConverterCollection.js"; import { ConverterCollection } from "~/utils/converters/ConverterCollection.js"; import type { CmsModel, StorageOperationsCmsModel } from "~/types/index.js"; -import type { SemVer } from "semver"; -import semver from "semver"; - -const featureVersion = semver.coerce("5.33.0") as SemVer; - -const isBetaOrNext = (model: CmsModel): boolean => { - if (!model.webinyVersion) { - return false; - } else if (model.webinyVersion.startsWith("0.0.0")) { - return true; - } - return model.webinyVersion.match(/next|beta|unstable/) !== null; -}; - -const isFeatureEnabled = (model: CmsModel): boolean => { - /** - * In case of disabled webinyVersion value, we disable this feature. - * This is only for testing... - */ - const disableConversion = !!process.env.WEBINY_API_TEST_STORAGE_ID_CONVERSION_DISABLE; - if (model.webinyVersion === "disable" || disableConversion) { - return false; - } - /** - * If is a test environment, always have this turned on. - */ - const nodeEnv = process.env.NODE_ENV as string; - if (nodeEnv === "test" || nodeEnv === "disable" || isBetaOrNext(model)) { - return true; - } - /** - * Possibility that the version is not defined, this means it is a quite old system where models did not change. - */ - if (!model.webinyVersion) { - return false; - } - /** - * In case feature version value is greater than the model version, feature is not enabled as it is an older model with no storageId. - * - * TODO change if necessary after the update to the system - */ - const modelVersion = semver.coerce(model.webinyVersion); - if (!modelVersion) { - console.log(`Warning: Model "${model.modelId}" does not have valid Webiny version set.`); - return true; - } else if (semver.compare(modelVersion, featureVersion) === -1) { - return false; - } - return true; -}; interface Params { /** @@ -75,12 +25,6 @@ interface ConverterCollectionConvertParams export const createValueKeyToStorageConverter = (params: Params): CmsModelConverterCallable => { const { plugins, model } = params; - if (isFeatureEnabled(model) === false) { - return ({ values }: ConverterCollectionConvertParams) => { - return values || {}; - }; - } - const converters = new ConverterCollection({ plugins }); @@ -97,12 +41,6 @@ export const createValueKeyToStorageConverter = (params: Params): CmsModelConver export const createValueKeyFromStorageConverter = (params: Params): CmsModelConverterCallable => { const { plugins, model } = params; - if (isFeatureEnabled(model) === false) { - return ({ values }: ConverterCollectionConvertParams) => { - return values || {}; - }; - } - const converters = new ConverterCollection({ plugins }); diff --git a/packages/api-mailer/__tests__/context/tenancySecurity.ts b/packages/api-mailer/__tests__/context/tenancySecurity.ts index 46f5f5ce1ae..e8aeebff22c 100644 --- a/packages/api-mailer/__tests__/context/tenancySecurity.ts +++ b/packages/api-mailer/__tests__/context/tenancySecurity.ts @@ -32,7 +32,6 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config) => { }, isInstalled: true, tags: [], - webinyVersion: context.WEBINY_VERSION, createdOn: new Date().toISOString(), savedOn: new Date().toISOString() }); diff --git a/packages/api-mailer/__tests__/graphQLHandler.ts b/packages/api-mailer/__tests__/graphQLHandler.ts index 719ddd19041..4baca4301b4 100644 --- a/packages/api-mailer/__tests__/graphQLHandler.ts +++ b/packages/api-mailer/__tests__/graphQLHandler.ts @@ -38,8 +38,7 @@ export const contextSecurity = ({ type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; }) diff --git a/packages/api-record-locking/__tests__/helpers/tenancySecurity.ts b/packages/api-record-locking/__tests__/helpers/tenancySecurity.ts index 99e18ee9610..23afb2e73ee 100644 --- a/packages/api-record-locking/__tests__/helpers/tenancySecurity.ts +++ b/packages/api-record-locking/__tests__/helpers/tenancySecurity.ts @@ -23,8 +23,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index 4dae01fb559..1a65103e465 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -38,16 +38,21 @@ const createContextPlugin = (params?: ICreateContextPluginParams) => { const timeout = getTimeout(params?.timeout); // Fetch CMS model to use for storing record locking data - const recordLockingModel = await identityContext.withoutAuthorization(() => { - return getModel.execute(RECORD_LOCKING_MODEL_ID); - }); + const [model, publicModels] = await identityContext.withoutAuthorization(async () => { + const [model, publicModels] = await Promise.all([ + // Get a record locking model + getModel.execute(RECORD_LOCKING_MODEL_ID), + // Get all models + listModels.execute({ includePrivate: false }) + ]); - const publicModels = await listModels.execute({ includePrivate: false }); + return [model.value, publicModels.value]; + }); // Register GraphQL schema plugin const graphQlPlugin = await createGraphQLSchema({ - model: recordLockingModel.value, - models: publicModels.value, + model, + models: publicModels, fieldTypePlugins: createFieldTypePluginRecords(context.plugins) }); @@ -56,7 +61,7 @@ const createContextPlugin = (params?: ICreateContextPluginParams) => { // Register features RecordLockingFeature.register(context.container, { timeout, - model: recordLockingModel.value + model }); }); plugin.name = "context.recordLocking"; diff --git a/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts index 5ce2b90a789..08f23576190 100644 --- a/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts +++ b/packages/api-scheduler/__tests__/mocks/context/tenancySecurity.ts @@ -55,8 +55,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-security-cognito/__tests__/tenancySecurity.ts b/packages/api-security-cognito/__tests__/tenancySecurity.ts index 04cec24202a..627bc388ba6 100644 --- a/packages/api-security-cognito/__tests__/tenancySecurity.ts +++ b/packages/api-security-cognito/__tests__/tenancySecurity.ts @@ -26,7 +26,6 @@ export const createTenancyAndSecurity = ({ fullAccess, identity }: Config = {}) description: "", createdOn: new Date().toISOString(), savedOn: new Date().toISOString(), - webinyVersion: process.env.WEBINY_VERSION as string }); context.security.addAuthenticator(async () => { diff --git a/packages/api-website-builder/src/context/BaseContext.ts b/packages/api-website-builder/src/context/BaseContext.ts index 242714d56f7..fe06187b51a 100644 --- a/packages/api-website-builder/src/context/BaseContext.ts +++ b/packages/api-website-builder/src/context/BaseContext.ts @@ -2,6 +2,8 @@ import { WebinyError } from "@webiny/error"; import type { WebsiteBuilderContext } from "./types.js"; import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; export abstract class BaseContext { protected context: WebsiteBuilderContext; @@ -29,14 +31,20 @@ export abstract class BaseContext { } protected async getModel(modelId: string) { - const model = await this.context.cms.getModel(modelId); - if (!model) { + const getModel = this.context.container.resolve(GetModelUseCase); + const identityContext = this.context.container.resolve(IdentityContext); + const modelResult = await identityContext.withoutAuthorization(() => { + return getModel.execute(modelId); + }); + + if (modelResult.isFail()) { + console.error("Get model error", modelResult.error.message); throw new WebinyError({ code: "MODEL_NOT_FOUND", message: `Content model "${modelId}" was not found!` }); } - return model; + return modelResult.value; } } diff --git a/packages/api-website-builder/src/context/pages/PagesStorage.ts b/packages/api-website-builder/src/context/pages/PagesStorage.ts index c440b2c58e3..121b332e307 100644 --- a/packages/api-website-builder/src/context/pages/PagesStorage.ts +++ b/packages/api-website-builder/src/context/pages/PagesStorage.ts @@ -95,7 +95,7 @@ export class PagesStorage implements WbPagesStorageOperations { public update = async ({ id, data }: WbPagesStorageOperationsUpdateParams): Promise => { const entry = await this.cms.getEntryById(this.getModel(), id); - const values = omit(data, ["id", "tenant", "webinyVersion"]); + const values = omit(data, ["id", "tenant"]); const updatedEntry = await this.cms.updateEntry(this.getModel(), entry.id, values); @@ -146,7 +146,6 @@ export class PagesStorage implements WbPagesStorageOperations { modifiedBy: entry.modifiedBy, locked: entry.locked, tenant: entry.tenant, - webinyVersion: entry.webinyVersion, ...entry.values } as WbPage; } diff --git a/packages/api-website-builder/src/context/pages/pages.types.ts b/packages/api-website-builder/src/context/pages/pages.types.ts index 79caac69029..cc6ba08e9a3 100644 --- a/packages/api-website-builder/src/context/pages/pages.types.ts +++ b/packages/api-website-builder/src/context/pages/pages.types.ts @@ -21,7 +21,6 @@ export interface WbPage { modifiedOn: string; modifiedBy: WbIdentity; tenant: string; - webinyVersion: string; properties: Record; metadata: Record; diff --git a/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts b/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts index 2da8130be27..d4278dba8cd 100644 --- a/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts +++ b/packages/api-website-builder/src/context/redirects/RedirectsStorage.ts @@ -67,7 +67,7 @@ export class RedirectsStorage implements WbRedirectsStorageOperations { }: WbRedirectsStorageOperationsUpdateParams): Promise => { const entry = await this.cms.getEntryById(this.model, id + "#0001"); - const values = omit(data, ["id", "tenant", "webinyVersion"]); + const values = omit(data, ["id", "tenant"]); const updatedEntry = await this.cms.updateEntry(this.model, entry.id, values); diff --git a/packages/api-website-builder/src/graphql/pages/pages.gql.ts b/packages/api-website-builder/src/graphql/pages/pages.gql.ts index 6b0951869a0..6575cf9fd2c 100644 --- a/packages/api-website-builder/src/graphql/pages/pages.gql.ts +++ b/packages/api-website-builder/src/graphql/pages/pages.gql.ts @@ -1,5 +1,5 @@ import { GetSettings } from "@webiny/api-core/features/GetSettings"; -import { UpdateSettings } from "@webiny/api-core/features/UpdateSettings"; +import { UpdateSettingsUseCase } from "@webiny/api-core/features/UpdateSettings"; import { ErrorResponse, GraphQLSchemaPlugin, @@ -156,7 +156,7 @@ export const createPagesSchema = () => { }, updateSettings: async (_, args, context) => { ensureAuthentication(context); - const saveSettings = context.container.resolve(UpdateSettings); + const saveSettings = context.container.resolve(UpdateSettingsUseCase); await saveSettings.execute({ name: WEBSITE_BUILDER_SETTINGS, @@ -167,7 +167,7 @@ export const createPagesSchema = () => { }, updateIntegrations: async (_, args, context) => { ensureAuthentication(context); - const saveSettings = context.container.resolve(UpdateSettings); + const saveSettings = context.container.resolve(UpdateSettingsUseCase); await saveSettings.execute({ name: WEBSITE_BUILDER_INTEGRATIONS, diff --git a/packages/api-websockets/__tests__/helpers/tenancySecurity.ts b/packages/api-websockets/__tests__/helpers/tenancySecurity.ts index 3314254aa25..b55cfcbc36d 100644 --- a/packages/api-websockets/__tests__/helpers/tenancySecurity.ts +++ b/packages/api-websockets/__tests__/helpers/tenancySecurity.ts @@ -22,8 +22,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu const mockSecurityContextPlugin = new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/api-workflows/src/context/index.ts b/packages/api-workflows/src/context/index.ts index ea40e921237..0a6a8975bb7 100644 --- a/packages/api-workflows/src/context/index.ts +++ b/packages/api-workflows/src/context/index.ts @@ -4,22 +4,35 @@ import { WorkflowsTransformer } from "~/context/transformer/WorkflowsTransformer import { WORKFLOW_STATE_MODEL_ID, WORKFLOW_MODEL_ID } from "~/constants.js"; import { WorkflowStateContext } from "./WorkflowStateContext.js"; import { WorkflowStateTransformer } from "./transformer/WorkflowStateTransformer.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { IdentityContext } from "@webiny/api-core/features/security/IdentityContext/index.js"; export const createContext = async ( - context: Pick + context: Pick< + Context, + "container" | "cms" | "security" | "workflows" | "workflowState" | "adminUsers" + > ) => { - const workflowModel = await context.cms.getModel(WORKFLOW_MODEL_ID); - const stateModel = await context.cms.getModel(WORKFLOW_STATE_MODEL_ID); + const identityContext = context.container.resolve(IdentityContext); + const getModel = context.container.resolve(GetModelUseCase); + + const workflowModel = await identityContext.withoutAuthorization(() => { + return getModel.execute(WORKFLOW_MODEL_ID); + }); + + const stateModel = await identityContext.withoutAuthorization(() => { + return getModel.execute(WORKFLOW_STATE_MODEL_ID); + }); context.workflows = new WorkflowsContext({ context, - model: workflowModel, + model: workflowModel.value, transformer: new WorkflowsTransformer() }); context.workflowState = new WorkflowStateContext({ context, - model: stateModel, + model: stateModel.value, transformer: new WorkflowStateTransformer() }); }; diff --git a/packages/api-workflows/src/index.ts b/packages/api-workflows/src/index.ts index e12d525de44..70ba751a44f 100644 --- a/packages/api-workflows/src/index.ts +++ b/packages/api-workflows/src/index.ts @@ -4,8 +4,8 @@ import { createWorkflowStateModel } from "~/context/models/stateModel.js"; import { ContextPlugin } from "@webiny/handler"; import type { Context } from "~/types.js"; import { createWorkflowsSchema } from "~/graphql/workflows.js"; -import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; import { createWorkflowStateSchema } from "~/graphql/workflowState.js"; +import { TenantContext } from "@webiny/api-core/features/TenantContext"; export * from "./context/errors/index.js"; @@ -22,9 +22,11 @@ export type { export const createWorkflows = () => { const plugin = new ContextPlugin(async context => { - if (!(await isHeadlessCmsReady(context))) { + const tenantContext = context.container.resolve(TenantContext); + if (!tenantContext.getTenant()) { return; } + if (!context.wcp.canUseWorkflows()) { return; } diff --git a/packages/app-aco/src/features/folders/getFolderExtensionsFields/GetFolderExtensionsFields.test.ts b/packages/app-aco/src/features/folders/getFolderExtensionsFields/GetFolderExtensionsFields.test.ts index 0a8ac36a901..df9e859bc2b 100644 --- a/packages/app-aco/src/features/folders/getFolderExtensionsFields/GetFolderExtensionsFields.test.ts +++ b/packages/app-aco/src/features/folders/getFolderExtensionsFields/GetFolderExtensionsFields.test.ts @@ -129,8 +129,7 @@ describe("GetFolderExtensionsFields", () => { authorization: { permissions: false }, - tags: ["type:model"], - webinyVersion: "0.0.0" + tags: ["type:model"] } as unknown as CmsModel; it("CMS: should return fields from `global`, `cms` and the provided model namespace", () => { diff --git a/packages/app-admin/src/base/createRootContainer.ts b/packages/app-admin/src/base/createRootContainer.ts index d1c6adf85f9..d748f42827a 100644 --- a/packages/app-admin/src/base/createRootContainer.ts +++ b/packages/app-admin/src/base/createRootContainer.ts @@ -30,7 +30,6 @@ export function createRootContainer() { telemetryUserId: process.env.REACT_APP_WEBINY_TELEMETRY_USER_ID, trashBinRetentionPeriodDays: trashBinRetention, wcpProjectId: process.env.REACT_APP_WCP_PROJECT_ID, - webinyVersion: String(process.env.REACT_APP_WEBINY_VERSION), websocketUrl: String(process.env.REACT_APP_WEBSOCKET_URL) }); diff --git a/packages/app-headless-cms-common/src/types/model.ts b/packages/app-headless-cms-common/src/types/model.ts index 053230958b7..685d744a7c6 100644 --- a/packages/app-headless-cms-common/src/types/model.ts +++ b/packages/app-headless-cms-common/src/types/model.ts @@ -84,7 +84,6 @@ export interface CmsModel { version: number; layout?: CmsEditorFieldsLayout; fields: CmsModelField[]; - lockedFields: CmsModelField[]; icon: string; name: string; modelId: string; diff --git a/packages/app-headless-cms/src/admin/components/FieldEditor/Field.tsx b/packages/app-headless-cms/src/admin/components/FieldEditor/Field.tsx index a77293ddf68..81afe24ee88 100644 --- a/packages/app-headless-cms/src/admin/components/FieldEditor/Field.tsx +++ b/packages/app-headless-cms/src/admin/components/FieldEditor/Field.tsx @@ -9,7 +9,7 @@ import type { CmsModelField, CmsEditorFieldOptionPlugin, CmsModel } from "~/type import { i18n } from "@webiny/app/i18n/index.js"; import { useModelEditor } from "~/admin/hooks/index.js"; import { useModelFieldEditor } from "~/admin/components/FieldEditor/useModelFieldEditor.js"; -import { useSnackbar, useConfirmationDialog } from "@webiny/app-admin"; +import { useSnackbar } from "@webiny/app-admin"; import { IconButton, Heading, Text, DropdownMenu, Tag } from "@webiny/admin-ui"; const t = i18n.ns("app-headless-cms/admin/components/editor/field"); @@ -84,20 +84,6 @@ const Field = (props: FieldProps) => { const { setData: setModel, data: model } = useModelEditor(); const { getFieldPlugin, getFieldRendererPlugin } = useModelFieldEditor(); - const { showConfirmation } = useConfirmationDialog({ - title: t`Warning - You are trying to delete a locked field!`, - message: ( - <> -

{t`You are about to delete a field which is used in the data storage`}

-

{t`All data in that field will be lost and there is no going back!`}

-

 

-

{t`Are you sure you want to continue?`}

- - ) - }); - const lockedFields = model?.lockedFields || []; - const isLocked = lockedFields.some(lockedField => lockedField.fieldId === field.storageId); - const removeFieldFromSelected = useCallback(async () => { if (model.titleFieldId === field.fieldId) { await setModel(data => { @@ -124,16 +110,10 @@ const Field = (props: FieldProps) => { }, [field.id, setModel, model]); const onDelete = useCallback(async () => { - if (!isLocked) { - await removeFieldFromSelected(); - props.onDelete(field); - return; - } - showConfirmation(async () => { - await removeFieldFromSelected(); - props.onDelete(field); - }); - }, [field.fieldId, lockedFields]); + await removeFieldFromSelected(); + props.onDelete(field); + return; + }, [field.fieldId]); const setAsTitle = useCallback(async (): Promise => { const response = await setModel(data => { diff --git a/packages/app-headless-cms/src/admin/graphql/contentModels.ts b/packages/app-headless-cms/src/admin/graphql/contentModels.ts index 72410a31389..ab4296b5bd8 100644 --- a/packages/app-headless-cms/src/admin/graphql/contentModels.ts +++ b/packages/app-headless-cms/src/admin/graphql/contentModels.ts @@ -60,7 +60,6 @@ export const MODEL_FIELDS = ` titleFieldId descriptionFieldId imageFieldId - lockedFields layout tags fields { diff --git a/packages/app-headless-cms/src/admin/plugins/fields/dateTime.tsx b/packages/app-headless-cms/src/admin/plugins/fields/dateTime.tsx index e92fba8e516..713db8d391a 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/dateTime.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/dateTime.tsx @@ -1,26 +1,14 @@ import React from "react"; -import get from "lodash/get.js"; -import type { CmsModelField, CmsModelFieldTypePlugin } from "~/types.js"; +import type { CmsModelFieldTypePlugin } from "~/types.js"; import { i18n } from "@webiny/app/i18n/index.js"; import { ReactComponent as DateTimeIcon } from "./icons/schedule-black-24px.svg"; -import { useModel, useModelField } from "~/admin/hooks/index.js"; import { Bind } from "@webiny/form"; import { Grid, Label, Select } from "@webiny/admin-ui"; const t = i18n.ns("app-headless-cms/admin/fields"); const DateTimeSettings = () => { - const { model } = useModel(); - const { field } = useModelField(); - const lockedFields = model.lockedFields || []; - const fieldId = get(field, "fieldId", null); - const lockedField = lockedFields.find( - lockedField => lockedField.fieldId === fieldId - ) as CmsModelField<{ - formatType: string; - }>; - return ( <> @@ -33,7 +21,6 @@ const DateTimeSettings = () => { description={t`(cannot be changed later)`} /> } - disabled={lockedField && Boolean(lockedField.formatType)} options={[ { value: "date", label: t`Date only` }, { value: "time", label: t`Time only` }, diff --git a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx index f9ef531f98d..f624ca4ad07 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx @@ -16,13 +16,7 @@ import { Grid, Label, MultiAutoComplete } from "@webiny/admin-ui"; const t = i18n.ns("app-headless-cms/admin/fields"); const RefFieldSettings = () => { - const { model } = useModel(); const { data: formData } = useForm(); - const lockedFields = model.lockedFields || []; - const fieldId = (formData || {}).fieldId || null; - const isFieldLocked = lockedFields.some( - lockedField => fieldId && lockedField.fieldId === fieldId - ); const { data, loading, error } = useQuery(LIST_CONTENT_MODELS); const { showSnackbar } = useSnackbar(); @@ -91,7 +85,7 @@ const RefFieldSettings = () => { ) } options={options} - disabled={isFieldLocked || loading} + disabled={loading} /> ); }} diff --git a/packages/app/src/features/envConfig/abstractions.ts b/packages/app/src/features/envConfig/abstractions.ts index f46b564431a..8b08aefb2bf 100644 --- a/packages/app/src/features/envConfig/abstractions.ts +++ b/packages/app/src/features/envConfig/abstractions.ts @@ -9,7 +9,6 @@ type Env = { telemetryUserId: string | undefined; trashBinRetentionPeriodDays: number; wcpProjectId: string | undefined; - webinyVersion: string; websocketUrl: string; graphqlClient?: { retries: { diff --git a/packages/migrations/src/utils/createLocaleEntity.ts b/packages/migrations/src/utils/createLocaleEntity.ts index 86a5bebe459..726a6937b60 100644 --- a/packages/migrations/src/utils/createLocaleEntity.ts +++ b/packages/migrations/src/utils/createLocaleEntity.ts @@ -15,9 +15,6 @@ export const createLocaleEntity = (table: Table) => { default: { type: "boolean" }, - webinyVersion: { - type: "string" - }, tenant: { type: "string" } diff --git a/packages/tasks/__tests__/helpers/tenancySecurity.ts b/packages/tasks/__tests__/helpers/tenancySecurity.ts index 3ab7765feb4..77acab6abd9 100644 --- a/packages/tasks/__tests__/helpers/tenancySecurity.ts +++ b/packages/tasks/__tests__/helpers/tenancySecurity.ts @@ -23,8 +23,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(context => { context.tenancy.setCurrentTenant({ id: context.request.headers["x-tenant"] || "root", - name: context.request.headers["x-tenant"] || "Root", - webinyVersion: context.WEBINY_VERSION + name: context.request.headers["x-tenant"] || "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { diff --git a/packages/tasks/__tests__/helpers/useGraphQLHandler.ts b/packages/tasks/__tests__/helpers/useGraphQLHandler.ts index c8df03aa109..5663e6e520d 100644 --- a/packages/tasks/__tests__/helpers/useGraphQLHandler.ts +++ b/packages/tasks/__tests__/helpers/useGraphQLHandler.ts @@ -79,8 +79,7 @@ export const useGraphQLHandler = (params?: UseHandlerParams) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/testing/src/context/plugins.ts b/packages/testing/src/context/plugins.ts index 0ed7af8beb2..b117628b69b 100644 --- a/packages/testing/src/context/plugins.ts +++ b/packages/testing/src/context/plugins.ts @@ -89,8 +89,7 @@ export const createHandlerCore = (params: CreateHandlerCoreParams = {}) => { type: "admin" }, description: "test", - createdOn: new Date().toISOString(), - webinyVersion: context.WEBINY_VERSION + createdOn: new Date().toISOString() }; }; } diff --git a/packages/testing/src/context/tenancySecurity.ts b/packages/testing/src/context/tenancySecurity.ts index 17ca7782e68..76e17b177b2 100644 --- a/packages/testing/src/context/tenancySecurity.ts +++ b/packages/testing/src/context/tenancySecurity.ts @@ -79,8 +79,7 @@ export const createTenancyAndSecurity = ({ permissions, identity }: Config): Plu new ContextPlugin(async context => { context.tenancy.setCurrentTenant({ id: "root", - name: "Root", - webinyVersion: context.WEBINY_VERSION + name: "Root" } as unknown as Tenant); context.security.addAuthenticator(async () => { From 793c67a572ada33950af3b29ff99ed4bcfa75e5d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 1 Dec 2025 17:43:58 +0100 Subject: [PATCH 64/71] chore: update deps and formatting --- packages/api-aco/src/types.ts | 6 +---- .../shared/TenantLinksRepository.ts | 2 +- .../DeleteSettings/DeleteSettingsUseCase.ts | 8 +++---- .../settings/DeleteSettings/abstractions.ts | 3 ++- .../UpdateSettings/UpdateSettingsUseCase.ts | 8 +++---- .../settings/UpdateSettings/abstractions.ts | 3 ++- .../CreateTenant/CreateTenantUseCase.ts | 2 +- .../users/CreateUser/CreateUserUseCase.ts | 2 +- packages/api-file-manager-ddb/package.json | 2 -- .../api-file-manager-ddb/tsconfig.build.json | 6 ----- packages/api-file-manager-ddb/tsconfig.json | 6 ----- packages/api-file-manager-s3/package.json | 2 +- .../api-file-manager-s3/tsconfig.build.json | 11 ++++----- packages/api-file-manager-s3/tsconfig.json | 11 ++++----- packages/api-file-manager/package.json | 3 --- .../file/CreateFilesInBatch/abstractions.ts | 3 ++- .../features/file/DeleteFile/abstractions.ts | 3 ++- .../file/ListTags/ListTagsRepository.ts | 6 ++++- .../UpdateSettings/UpdateSettingsUseCase.ts | 6 ++--- .../settings/UpdateSettings/abstractions.ts | 3 ++- .../settings/UpdateSettings/events.ts | 10 ++++---- packages/api-file-manager/tsconfig.build.json | 6 ----- packages/api-file-manager/tsconfig.json | 6 ----- .../src/tasks/createIndexTaskPlugin.ts | 2 +- packages/api-headless-cms-ddb-es/src/types.ts | 5 +--- .../src/operations/entry/dataLoaders.ts | 2 +- ...CancelScheduledActionOnUnpublishHandler.ts | 9 +++---- .../ScheduleEntryAction/abstractions.ts | 4 +++- .../__tests__/parameters/header.test.ts | 17 ++++++------- packages/api-headless-cms/__tests__/types.ts | 7 +----- packages/api-headless-cms/package.json | 1 - .../contentEntry/CreateEntry/abstractions.ts | 12 ++++++---- .../CreateEntryRevisionFrom/abstractions.ts | 5 ++-- .../CreateEntryRevisionFrom/events.ts | 21 +++++++--------- .../contentEntry/DeleteEntry/events.ts | 10 ++++---- .../DeleteEntryRevisionUseCase.ts | 4 +++- .../DeleteEntryRevision/abstractions.ts | 3 ++- .../GetEntriesByIds/abstractions.ts | 5 ++-- .../GetLatestEntriesByIds/abstractions.ts | 3 ++- ...etLatestEntriesByIdsNotDeletedDecorator.ts | 4 +++- .../GetLatestRevisionByEntryIdRepository.ts | 12 ++++++++-- ...evisionByEntryIdIncludingDeletedUseCase.ts | 11 +++++++-- .../GetPublishedEntriesByIds/abstractions.ts | 11 +++++---- ...ublishedEntriesByIdsNotDeletedDecorator.ts | 4 +++- .../GetRevisionById/abstractions.ts | 5 ++-- .../GetRevisionsByEntryId/abstractions.ts | 3 ++- .../GetUniqueFieldValues/abstractions.ts | 3 +-- .../contentEntry/ListEntries/abstractions.ts | 5 ++-- .../features/contentEntry/MoveEntry/events.ts | 15 +++++------- .../contentEntry/PublishEntry/abstractions.ts | 5 ++-- .../RepublishEntry/abstractions.ts | 5 ++-- .../RestoreEntryFromBin/abstractions.ts | 3 ++- .../RestoreEntryFromBin/events.ts | 21 +++++++--------- .../UnpublishEntry/abstractions.ts | 5 ++-- .../contentEntry/UpdateEntry/abstractions.ts | 5 ++-- .../contentEntry/UpdateEntry/events.ts | 5 ++-- .../contentModel/CreateModel/abstractions.ts | 5 ++-- .../CreateModelFrom/abstractions.ts | 8 +++---- .../InitializeModel/abstractions.ts | 5 ++-- .../contentModel/InitializeModel/events.ts | 5 ++-- .../contentModel/UpdateModel/abstractions.ts | 3 ++- .../contentModelGroup/CreateGroup/events.ts | 10 ++++---- .../contentModelGroup/DeleteGroup/events.ts | 10 ++++---- .../GetGroup/abstractions.ts | 5 +++- .../UpdateGroup/UpdateGroupUseCase.ts | 9 +++++-- .../UpdateGroup/abstractions.ts | 10 ++++---- .../contentModelGroup/UpdateGroup/events.ts | 10 ++++---- .../shared/PluginGroupsProvider.ts | 2 +- .../api-headless-cms/src/graphqlFields/ref.ts | 9 +++++-- .../src/plugins/CmsGroupPlugin.ts | 3 +-- .../src/plugins/CmsModelPlugin.ts | 3 +-- .../api-headless-cms/src/types/context.ts | 5 +++- packages/api-record-locking/package.json | 3 +-- .../api-record-locking/src/domain/errors.ts | 6 ++++- .../GetLockedEntryLockRecord/abstractions.ts | 7 ++++-- .../IsEntryLocked/IsEntryLockedUseCase.ts | 5 +--- .../features/IsEntryLocked/abstractions.ts | 3 ++- .../ListAllLockRecordsUseCase.ts | 4 +++- .../ListAllLockRecords/abstractions.ts | 24 ++++++++++++++----- .../ListLockRecords/ListLockRecordsUseCase.ts | 4 +++- .../features/ListLockRecords/abstractions.ts | 7 ++++-- .../src/features/LockEntry/abstractions.ts | 6 ++++- .../src/features/LockEntry/events.ts | 15 +++++------- .../src/features/UnlockEntry/events.ts | 10 ++++---- .../UnlockEntryRequest/abstractions.ts | 11 ++++++--- .../src/features/UnlockEntryRequest/events.ts | 18 +++++++------- .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 17 ++++++++++--- .../features/UpdateEntryLock/abstractions.ts | 19 +++++++++++---- .../api-record-locking/tsconfig.build.json | 16 ++++--------- packages/api-record-locking/tsconfig.json | 16 ++++--------- .../api-scheduler/__tests__/Scheduler.test.ts | 4 +++- .../ExecuteScheduledAction/abstractions.ts | 17 +++++++------ .../__tests__/tenancySecurity.ts | 2 +- packages/api-websockets/package.json | 1 + packages/api-websockets/tsconfig.build.json | 5 ++++ packages/api-websockets/tsconfig.json | 5 ++++ packages/project-aws/package.json | 1 + packages/project-aws/tsconfig.build.json | 21 +++++++++++++--- packages/project-aws/tsconfig.json | 21 +++++++++++++--- packages/tasks/src/domain/errors.ts | 1 - packages/testing/tsconfig.build.json | 3 --- packages/testing/tsconfig.json | 3 --- yarn.lock | 13 ++++------ 103 files changed, 398 insertions(+), 342 deletions(-) diff --git a/packages/api-aco/src/types.ts b/packages/api-aco/src/types.ts index 64eef74a25e..906e3a8e8fe 100644 --- a/packages/api-aco/src/types.ts +++ b/packages/api-aco/src/types.ts @@ -67,10 +67,6 @@ export interface AcoStorageOperations { flp: AcoFolderLevelPermissionsStorageOperations; } -export interface AcoContext - extends BaseContext, - ApiCoreContext, - CmsContext, - TasksContext { +export interface AcoContext extends BaseContext, ApiCoreContext, CmsContext, TasksContext { aco: AdvancedContentOrganisation; } diff --git a/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts b/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts index 3e28af3df73..8e743482bfe 100644 --- a/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts +++ b/packages/api-core/src/features/security/tenantLinks/shared/TenantLinksRepository.ts @@ -24,7 +24,7 @@ class TenantLinksRepositoryImpl implements RepositoryAbstraction.Interface { await this.storageOperations.createTenantLinks( inputs.map(input => ({ ...input, - createdOn: new Date().toISOString(), + createdOn: new Date().toISOString() })) ); return Result.ok(); diff --git a/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts b/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts index abb8d7dd440..a89bb6cf122 100644 --- a/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts +++ b/packages/api-core/src/features/settings/DeleteSettings/DeleteSettingsUseCase.ts @@ -1,17 +1,17 @@ import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { DeleteSettingsUseCase } from "./abstractions.js"; +import { DeleteSettingsUseCase as UseCase } from "./abstractions.js"; import { SettingsRepository } from "../shared/abstractions.js"; import { EventPublisher } from "~/features/eventPublisher/abstractions.js"; import { SettingsBeforeDeleteEvent, SettingsAfterDeleteEvent } from "./events.js"; -class DeleteSettingsUseCaseImpl implements DeleteSettingsUseCase.Interface { +class DeleteSettingsUseCaseImpl implements UseCase.Interface { constructor( private repository: SettingsRepository.Interface, private eventPublisher: EventPublisher.Interface ) {} - async execute(name: string): Promise> { + async execute(name: string): Promise> { // Get existing settings first (needed for events) const getResult = await this.repository.get(name); if (getResult.isFail()) { @@ -37,7 +37,7 @@ class DeleteSettingsUseCaseImpl implements DeleteSettingsUseCase.Interface { } export const DeleteSettingsUseCase = createImplementation({ - abstraction: DeleteSettingsUseCase, + abstraction: UseCase, implementation: DeleteSettingsUseCaseImpl, dependencies: [SettingsRepository, EventPublisher] }); diff --git a/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts b/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts index 2849000ae43..543d8388473 100644 --- a/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts +++ b/packages/api-core/src/features/settings/DeleteSettings/abstractions.ts @@ -15,7 +15,8 @@ export interface IDeleteSettingsUseCase { execute(name: string): Promise>; } -export const DeleteSettingsUseCase = createAbstraction("DeleteSettingsUseCase"); +export const DeleteSettingsUseCase = + createAbstraction("DeleteSettingsUseCase"); export namespace DeleteSettingsUseCase { export type Interface = IDeleteSettingsUseCase; diff --git a/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index e98c1cd4705..f8ae8795fff 100644 --- a/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-core/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -1,6 +1,6 @@ import { createImplementation } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { UpdateSettingsUseCase } from "./abstractions.js"; +import { UpdateSettingsUseCase as UseCase } from "./abstractions.js"; import { SettingsRepository } from "../shared/abstractions.js"; import { EventPublisher } from "~/features/eventPublisher/abstractions.js"; import { type ISettings, SettingsModelFactory } from "~/domain/settings/index.js"; @@ -8,14 +8,14 @@ import type { IUpdateSettingsInput } from "../shared/types.js"; import { SettingsValidationError } from "../shared/errors.js"; import { SettingsBeforeUpdateEvent, SettingsAfterUpdateEvent } from "./events.js"; -class UpdateSettingsUseCaseImpl implements UpdateSettingsUseCase.Interface { +class UpdateSettingsUseCaseImpl implements UseCase.Interface { constructor( private repository: SettingsRepository.Interface, private eventPublisher: EventPublisher.Interface, private modelFactory: SettingsModelFactory.Interface ) {} - async execute(input: IUpdateSettingsInput): Promise> { + async execute(input: IUpdateSettingsInput): Promise> { // Validation if (!input.name || input.name.trim().length === 0) { return Result.fail(new SettingsValidationError("Settings name is required")); @@ -63,7 +63,7 @@ class UpdateSettingsUseCaseImpl implements UpdateSettingsUseCase.Interface { } export const UpdateSettingsUseCase = createImplementation({ - abstraction: UpdateSettingsUseCase, + abstraction: UseCase, implementation: UpdateSettingsUseCaseImpl, dependencies: [SettingsRepository, EventPublisher, SettingsModelFactory] }); diff --git a/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts b/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts index 1d25a947b50..8fd0dbdd5db 100644 --- a/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts +++ b/packages/api-core/src/features/settings/UpdateSettings/abstractions.ts @@ -18,7 +18,8 @@ export interface IUpdateSettingsUseCase { execute(input: IUpdateSettingsInput): Promise>; } -export const UpdateSettingsUseCase = createAbstraction("UpdateSettingsUseCase"); +export const UpdateSettingsUseCase = + createAbstraction("UpdateSettingsUseCase"); export namespace UpdateSettingsUseCase { export type Interface = IUpdateSettingsUseCase; diff --git a/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts b/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts index 9d8dac61599..68a5675bada 100644 --- a/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts +++ b/packages/api-core/src/features/tenancy/CreateTenant/CreateTenantUseCase.ts @@ -24,7 +24,7 @@ class CreateTenantUseCaseImpl implements UseCaseAbstraction.Interface { }, savedOn: new Date().toISOString(), createdOn: new Date().toISOString(), - parent: data.parent || null, + parent: data.parent || null }; await this.eventPublisher.publish(new TenantBeforeCreateEvent({ tenant, input: data })); diff --git a/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts b/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts index 0e9a4c0f6f2..870fb3226cd 100644 --- a/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts +++ b/packages/api-core/src/features/users/CreateUser/CreateUserUseCase.ts @@ -78,7 +78,7 @@ class CreateUserUseCaseImpl implements UseCaseAbstraction.Interface { displayName, createdOn: new Date().toISOString(), createdBy, - tenant, + tenant }; // 8. Publish before event diff --git a/packages/api-file-manager-ddb/package.json b/packages/api-file-manager-ddb/package.json index 8234f82d324..e1699b0dce8 100644 --- a/packages/api-file-manager-ddb/package.json +++ b/packages/api-file-manager-ddb/package.json @@ -21,12 +21,10 @@ ], "license": "MIT", "dependencies": { - "@webiny/api": "0.0.0", "@webiny/api-file-manager": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/build-tools": "0.0.0", "@webiny/db-dynamodb": "0.0.0", - "@webiny/error": "0.0.0", "@webiny/plugins": "0.0.0" }, "devDependencies": { diff --git a/packages/api-file-manager-ddb/tsconfig.build.json b/packages/api-file-manager-ddb/tsconfig.build.json index f70a3956977..e97b9f2b2b1 100644 --- a/packages/api-file-manager-ddb/tsconfig.build.json +++ b/packages/api-file-manager-ddb/tsconfig.build.json @@ -2,11 +2,9 @@ "extends": "../../tsconfig.build.json", "include": ["src"], "references": [ - { "path": "../api/tsconfig.build.json" }, { "path": "../api-file-manager/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, - { "path": "../error/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" } ], "compilerOptions": { @@ -16,16 +14,12 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/api/*": ["../api/src/*"], - "@webiny/api": ["../api/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"] }, diff --git a/packages/api-file-manager-ddb/tsconfig.json b/packages/api-file-manager-ddb/tsconfig.json index 48b914cf0c1..ac563285118 100644 --- a/packages/api-file-manager-ddb/tsconfig.json +++ b/packages/api-file-manager-ddb/tsconfig.json @@ -2,11 +2,9 @@ "extends": "../../tsconfig.json", "include": ["src", "__tests__"], "references": [ - { "path": "../api" }, { "path": "../api-file-manager" }, { "path": "../aws-sdk" }, { "path": "../db-dynamodb" }, - { "path": "../error" }, { "path": "../plugins" } ], "compilerOptions": { @@ -16,16 +14,12 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/api/*": ["../api/src/*"], - "@webiny/api": ["../api/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/db-dynamodb/*": ["../db-dynamodb/src/*"], "@webiny/db-dynamodb": ["../db-dynamodb/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"] }, diff --git a/packages/api-file-manager-s3/package.json b/packages/api-file-manager-s3/package.json index 5a67eb1e250..013ae8028e7 100644 --- a/packages/api-file-manager-s3/package.json +++ b/packages/api-file-manager-s3/package.json @@ -16,7 +16,7 @@ "@webiny/api-file-manager": "0.0.0", "@webiny/api-websockets": "0.0.0", "@webiny/aws-sdk": "0.0.0", - "@webiny/error": "0.0.0", + "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/handler-graphql": "0.0.0", diff --git a/packages/api-file-manager-s3/tsconfig.build.json b/packages/api-file-manager-s3/tsconfig.build.json index 26f30da0183..a0e0e201b07 100644 --- a/packages/api-file-manager-s3/tsconfig.build.json +++ b/packages/api-file-manager-s3/tsconfig.build.json @@ -7,7 +7,7 @@ { "path": "../api-file-manager/tsconfig.build.json" }, { "path": "../api-websockets/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, - { "path": "../error/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, @@ -153,15 +153,14 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], "@webiny/aws-sdk": ["../aws-sdk/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-file-manager-s3/tsconfig.json b/packages/api-file-manager-s3/tsconfig.json index b6ce8d8cd42..b37c3ed6bb8 100644 --- a/packages/api-file-manager-s3/tsconfig.json +++ b/packages/api-file-manager-s3/tsconfig.json @@ -7,7 +7,7 @@ { "path": "../api-file-manager" }, { "path": "../api-websockets" }, { "path": "../aws-sdk" }, - { "path": "../error" }, + { "path": "../feature" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../handler-graphql" }, @@ -153,15 +153,14 @@ "@webiny/api-core": ["../api-core/src"], "@webiny/api-file-manager/*": ["../api-file-manager/src/*"], "@webiny/api-file-manager": ["../api-file-manager/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], "@webiny/aws-sdk": ["../aws-sdk/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-file-manager/package.json b/packages/api-file-manager/package.json index 9067feca2fc..b7356233412 100644 --- a/packages/api-file-manager/package.json +++ b/packages/api-file-manager/package.json @@ -30,11 +30,8 @@ "@webiny/handler-aws": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", - "@webiny/pubsub": "0.0.0", - "@webiny/tasks": "0.0.0", "@webiny/wcp": "0.0.0", "cache-control-parser": "^2.0.6", - "lodash": "^4.17.21", "object-hash": "^3.0.0", "zod": "^3.25.76" }, diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts index 4302f6841e1..732c6514453 100644 --- a/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/abstractions.ts @@ -23,7 +23,8 @@ export interface ICreateFilesInBatchRepositoryErrors { persistence: FilePersistenceError; } -type RepositoryError = ICreateFilesInBatchRepositoryErrors[keyof ICreateFilesInBatchRepositoryErrors]; +type RepositoryError = + ICreateFilesInBatchRepositoryErrors[keyof ICreateFilesInBatchRepositoryErrors]; export const CreateFilesInBatchRepository = createAbstraction( "CreateFilesInBatchRepository" diff --git a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts index e26b5af01fb..c7d27c13f55 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts @@ -25,7 +25,8 @@ export interface IDeleteFileRepositoryErrors { type RepositoryError = IDeleteFileRepositoryErrors[keyof IDeleteFileRepositoryErrors]; -export const DeleteFileRepository = createAbstraction("DeleteFileRepository"); +export const DeleteFileRepository = + createAbstraction("DeleteFileRepository"); export namespace DeleteFileRepository { export type Interface = IDeleteFileRepository; diff --git a/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts index f65e40c6657..af3143ed04f 100644 --- a/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts +++ b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts @@ -1,7 +1,11 @@ import { Result } from "@webiny/feature/api"; import { GetUniqueFieldValuesUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetUniqueFieldValues"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; -import { ListTagsRepository as RepositoryAbstraction, ListTagsInput, TagItem } from "./abstractions.js"; +import { + ListTagsRepository as RepositoryAbstraction, + ListTagsInput, + TagItem +} from "./abstractions.js"; import { FileModel } from "~/domain/file/abstractions.js"; import { FilePersistenceError } from "~/domain/file/errors.js"; diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts index f811197bd25..55a342ba22a 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/UpdateSettingsUseCase.ts @@ -1,6 +1,6 @@ import { Result } from "@webiny/feature/api"; import { UpdateSettingsUseCase as UseCaseAbstraction } from "./abstractions.js"; -import { UpdateSettingsUseCase } from "@webiny/api-core/features/settings/UpdateSettings"; +import { UpdateSettingsUseCase as CoreUpdateSettingsUseCase } from "@webiny/api-core/features/settings/UpdateSettings"; import { GetSettingsUseCase } from "../GetSettings/abstractions.js"; import type { FileManagerSettings } from "~/domain/settings/types.js"; import type { UpdateSettingsInput } from "~/domain/settings/types.js"; @@ -14,7 +14,7 @@ import { createZodError } from "@webiny/utils"; class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { constructor( - private updateSettings: UpdateSettingsUseCase.Interface, + private updateSettings: CoreUpdateSettingsUseCase.Interface, private getSettings: GetSettingsUseCase.Interface, private eventPublisher: EventPublisher.Interface ) {} @@ -76,5 +76,5 @@ class UpdateSettingsUseCaseImpl implements UseCaseAbstraction.Interface { export const UpdateSettingsUseCase = UseCaseAbstraction.createImplementation({ implementation: UpdateSettingsUseCaseImpl, - dependencies: [UpdateSettingsUseCase, GetSettingsUseCase, EventPublisher] + dependencies: [CoreUpdateSettingsUseCase, GetSettingsUseCase, EventPublisher] }); diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts index a37238d6cad..7f517479277 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/abstractions.ts @@ -19,7 +19,8 @@ export interface IUpdateSettingsUseCaseErrors { type UseCaseError = IUpdateSettingsUseCaseErrors[keyof IUpdateSettingsUseCaseErrors]; -export const UpdateSettingsUseCase = createAbstraction("UpdateSettingsUseCase"); +export const UpdateSettingsUseCase = + createAbstraction("UpdateSettingsUseCase"); export namespace UpdateSettingsUseCase { export type Interface = IUpdateSettingsUseCase; diff --git a/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts b/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts index c3263a38bed..73fd8e983c4 100644 --- a/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts +++ b/packages/api-file-manager/src/features/settings/UpdateSettings/events.ts @@ -22,8 +22,9 @@ export class SettingsBeforeUpdateEvent extends DomainEvent>("SettingsBeforeUpdateHandler"); +export const SettingsBeforeUpdateHandler = createAbstraction< + IEventHandler +>("SettingsBeforeUpdateHandler"); export namespace SettingsBeforeUpdateHandler { export type Interface = IEventHandler; @@ -48,8 +49,9 @@ export class SettingsAfterUpdateEvent extends DomainEvent>("SettingsAfterUpdateHandler"); +export const SettingsAfterUpdateHandler = createAbstraction< + IEventHandler +>("SettingsAfterUpdateHandler"); export namespace SettingsAfterUpdateHandler { export type Interface = IEventHandler; diff --git a/packages/api-file-manager/tsconfig.build.json b/packages/api-file-manager/tsconfig.build.json index 354b188a5dd..4b25cea2c6c 100644 --- a/packages/api-file-manager/tsconfig.build.json +++ b/packages/api-file-manager/tsconfig.build.json @@ -12,8 +12,6 @@ { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, - { "path": "../pubsub/tsconfig.build.json" }, - { "path": "../tasks/tsconfig.build.json" }, { "path": "../wcp/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" } ], @@ -170,10 +168,6 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], - "@webiny/tasks/*": ["../tasks/src/*"], - "@webiny/tasks": ["../tasks/src"], "@webiny/wcp/*": ["../wcp/src/*"], "@webiny/wcp": ["../wcp/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/api-file-manager/tsconfig.json b/packages/api-file-manager/tsconfig.json index ebea88e95a9..41d2b14a19f 100644 --- a/packages/api-file-manager/tsconfig.json +++ b/packages/api-file-manager/tsconfig.json @@ -12,8 +12,6 @@ { "path": "../handler-aws" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, - { "path": "../pubsub" }, - { "path": "../tasks" }, { "path": "../wcp" }, { "path": "../utils" } ], @@ -170,10 +168,6 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], - "@webiny/tasks/*": ["../tasks/src/*"], - "@webiny/tasks": ["../tasks/src"], "@webiny/wcp/*": ["../wcp/src/*"], "@webiny/wcp": ["../wcp/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts b/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts index 5a702872d65..2dd6fe3107f 100644 --- a/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts +++ b/packages/api-headless-cms-ddb-es/src/tasks/createIndexTaskPlugin.ts @@ -33,7 +33,7 @@ export const createIndexTaskPluginTest = () => { return { index, settings: configurations.indexSettings({ - context, + context }) }; }); diff --git a/packages/api-headless-cms-ddb-es/src/types.ts b/packages/api-headless-cms-ddb-es/src/types.ts index 205a2c01eb5..d0309fa0dff 100644 --- a/packages/api-headless-cms-ddb-es/src/types.ts +++ b/packages/api-headless-cms-ddb-es/src/types.ts @@ -176,10 +176,7 @@ export interface CmsContext extends BaseCmsContext { export interface HeadlessCmsStorageOperations extends BaseHeadlessCmsStorageOperations { getTable: () => Table; getEsTable: () => Table; - getEntities: () => Record< - "groups" | "models" | "entries" | "entriesEs", - Entity - >; + getEntities: () => Record<"groups" | "models" | "entries" | "entriesEs", Entity>; } export interface StorageOperationsFactory { diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts index 1f325e06d6c..de7fb5bec00 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoaders.ts @@ -80,7 +80,7 @@ export class DataLoadersHandler implements DataLoadersHandlerInterface { const factory = getDataLoaderFactory(name); loader = factory({ entity: this.entity, - tenant: model.tenant, + tenant: model.tenant }); this.cache.setDataLoader(cacheParams, loader); return loader; diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts index 4b6780ee347..959b40a9d3b 100644 --- a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts @@ -31,7 +31,8 @@ class CancelScheduledActionOnUnpublishHandlerImpl implements EntryAfterUnpublish } } -export const CancelScheduledActionOnUnpublishHandler = EntryAfterUnpublishHandler.createImplementation({ - implementation: CancelScheduledActionOnUnpublishHandlerImpl, - dependencies: [CancelScheduledEntryActionUseCase] -}); +export const CancelScheduledActionOnUnpublishHandler = + EntryAfterUnpublishHandler.createImplementation({ + implementation: CancelScheduledActionOnUnpublishHandlerImpl, + dependencies: [CancelScheduledEntryActionUseCase] + }); diff --git a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts index f30e152a219..882da727f21 100644 --- a/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts +++ b/packages/api-headless-cms-scheduler/src/features/ScheduleEntryAction/abstractions.ts @@ -40,7 +40,9 @@ export interface IScheduleEntryActionErrors { type ScheduleEntryActionError = IScheduleEntryActionErrors[keyof IScheduleEntryActionErrors]; export interface IScheduleEntryActionUseCase { - execute(input: IScheduleEntryActionInput): Promise>; + execute( + input: IScheduleEntryActionInput + ): Promise>; } export const ScheduleEntryActionUseCase = createAbstraction( diff --git a/packages/api-headless-cms/__tests__/parameters/header.test.ts b/packages/api-headless-cms/__tests__/parameters/header.test.ts index e15e9f90ddf..d5e6ebb5890 100644 --- a/packages/api-headless-cms/__tests__/parameters/header.test.ts +++ b/packages/api-headless-cms/__tests__/parameters/header.test.ts @@ -21,18 +21,15 @@ const createContext = (type?: ApiEndpoint | null): CmsContext => { const correctTestCases: [ApiEndpoint][] = [["manage"], ["read"], ["preview"]]; describe("Header Parameter Plugin", () => { - it.each(correctTestCases)( - "should properly extract type from headers - %s", - async type => { - const plugin = createHeaderParameterPlugin(); + it.each(correctTestCases)("should properly extract type from headers - %s", async type => { + const plugin = createHeaderParameterPlugin(); - const result = await plugin.getParameters(createContext(type)); + const result = await plugin.getParameters(createContext(type)); - expect(result).toEqual({ - type - }); - } - ); + expect(result).toEqual({ + type + }); + }); it("should return null on missing both headers - code will move onto the next available plugin", async () => { const plugin = createHeaderParameterPlugin(); diff --git a/packages/api-headless-cms/__tests__/types.ts b/packages/api-headless-cms/__tests__/types.ts index 75684aaa210..c7421710f63 100644 --- a/packages/api-headless-cms/__tests__/types.ts +++ b/packages/api-headless-cms/__tests__/types.ts @@ -4,12 +4,7 @@ import type { useProductManageHandler } from "./testHelpers/useProductManageHand export type CmsModel = Omit< BaseCmsModel, - | "locale" - | "tenant" - | "createdOn" - | "createdBy" - | "savedOn" - | "isPrivate" + "locale" | "tenant" | "createdOn" | "createdBy" | "savedOn" | "isPrivate" >; export type CmsGroup = Omit; /** diff --git a/packages/api-headless-cms/package.json b/packages/api-headless-cms/package.json index 0ba63a023df..55b8cc04157 100644 --- a/packages/api-headless-cms/package.json +++ b/packages/api-headless-cms/package.json @@ -45,7 +45,6 @@ "p-map": "^7.0.3", "p-reduce": "^3.0.0", "pluralize": "^8.0.0", - "semver": "^7.7.2", "slugify": "^1.6.6", "zod": "^3.25.76" }, diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts index 63dfa5f8b6b..e62a112ae71 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/abstractions.ts @@ -1,6 +1,11 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import type { CmsEntry, CmsModel, CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; +import type { + CmsEntry, + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "~/types/index.js"; import type { EntryPersistenceError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import type { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; @@ -44,9 +49,8 @@ export interface ICreateEntryRepositoryErrors { type RepositoryError = ICreateEntryRepositoryErrors[keyof ICreateEntryRepositoryErrors]; -export const CreateEntryRepository = createAbstraction( - "CreateEntryRepository" -); +export const CreateEntryRepository = + createAbstraction("CreateEntryRepository"); export namespace CreateEntryRepository { export type Interface = ICreateEntryRepository; diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts index 034f42b81de..57170935276 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/abstractions.ts @@ -35,8 +35,9 @@ export interface ICreateEntryRevisionFromUseCaseErrors { type UseCaseError = ICreateEntryRevisionFromUseCaseErrors[keyof ICreateEntryRevisionFromUseCaseErrors]; -export const CreateEntryRevisionFromUseCase = - createAbstraction("CreateEntryRevisionFromUseCase"); +export const CreateEntryRevisionFromUseCase = createAbstraction( + "CreateEntryRevisionFromUseCase" +); export namespace CreateEntryRevisionFromUseCase { export type Interface = ICreateEntryRevisionFromUseCase; diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts index a0e4922545f..d68d1bec5f9 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntryRevisionFrom/events.ts @@ -18,10 +18,9 @@ export class EntryRevisionBeforeCreateEvent extends DomainEvent>( - "EntryRevisionBeforeCreateHandler" - ); +export const EntryRevisionBeforeCreateHandler = createAbstraction< + IEventHandler +>("EntryRevisionBeforeCreateHandler"); export namespace EntryRevisionBeforeCreateHandler { export type Interface = IEventHandler; @@ -39,10 +38,9 @@ export class EntryRevisionAfterCreateEvent extends DomainEvent>( - "EntryRevisionAfterCreateHandler" - ); +export const EntryRevisionAfterCreateHandler = createAbstraction< + IEventHandler +>("EntryRevisionAfterCreateHandler"); export namespace EntryRevisionAfterCreateHandler { export type Interface = IEventHandler; @@ -60,10 +58,9 @@ export class EntryRevisionCreateErrorEvent extends DomainEvent>( - "EntryRevisionCreateErrorHandler" - ); +export const EntryRevisionCreateErrorHandler = createAbstraction< + IEventHandler +>("EntryRevisionCreateErrorHandler"); export namespace EntryRevisionCreateErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts index e3ef560e1cf..9d63657c91d 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntry/events.ts @@ -38,9 +38,8 @@ export class EntryAfterDeleteEvent extends DomainEvent } } -export const EntryAfterDeleteHandler = createAbstraction>( - "EntryAfterDeleteHandler" -); +export const EntryAfterDeleteHandler = + createAbstraction>("EntryAfterDeleteHandler"); export namespace EntryAfterDeleteHandler { export type Interface = IEventHandler; @@ -58,9 +57,8 @@ export class EntryDeleteErrorEvent extends DomainEvent } } -export const EntryDeleteErrorHandler = createAbstraction>( - "EntryDeleteErrorHandler" -); +export const EntryDeleteErrorHandler = + createAbstraction>("EntryDeleteErrorHandler"); export namespace EntryDeleteErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts index 3b1e43f9633..65a58f0648c 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/DeleteEntryRevisionUseCase.ts @@ -89,7 +89,9 @@ class DeleteEntryRevisionUseCaseImpl implements UseCaseAbstraction.Interface { // If targeted record is the latest entry record and there is no previous revision, // delete the entire entry. - const previousRevision = previousRevisionResult.isFail() ? null : previousRevisionResult.value; + const previousRevision = previousRevisionResult.isFail() + ? null + : previousRevisionResult.value; if (previousRevisionResult.isFail() && entryToDelete.id === latestRevisionId) { return await this.deleteEntry.execute(model, revisionId, {}); } diff --git a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts index db0405c6644..b428b64a15c 100644 --- a/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/DeleteEntryRevision/abstractions.ts @@ -69,7 +69,8 @@ export interface IDeleteEntryRevisionRepositoryErrors { storage: EntryPersistenceError; } -type RepositoryError = IDeleteEntryRevisionRepositoryErrors[keyof IDeleteEntryRevisionRepositoryErrors]; +type RepositoryError = + IDeleteEntryRevisionRepositoryErrors[keyof IDeleteEntryRevisionRepositoryErrors]; export const DeleteEntryRevisionRepository = createAbstraction( "DeleteEntryRevisionRepository" diff --git a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts index 9f9356e65a8..77f82cb464b 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetEntriesByIds/abstractions.ts @@ -22,9 +22,8 @@ export interface IGetEntriesByIdsUseCaseErrors { type UseCaseError = IGetEntriesByIdsUseCaseErrors[keyof IGetEntriesByIdsUseCaseErrors]; -export const GetEntriesByIdsUseCase = createAbstraction( - "GetEntriesByIdsUseCase" -); +export const GetEntriesByIdsUseCase = + createAbstraction("GetEntriesByIdsUseCase"); export namespace GetEntriesByIdsUseCase { export type Interface = IGetEntriesByIdsUseCase; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts index f9c40232dc4..755603b8fc4 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/abstractions.ts @@ -45,7 +45,8 @@ export interface IGetLatestEntriesByIdsRepositoryErrors { storage: EntryPersistenceError; } -type RepositoryError = IGetLatestEntriesByIdsRepositoryErrors[keyof IGetLatestEntriesByIdsRepositoryErrors]; +type RepositoryError = + IGetLatestEntriesByIdsRepositoryErrors[keyof IGetLatestEntriesByIdsRepositoryErrors]; export const GetLatestEntriesByIdsRepository = createAbstraction( "GetLatestEntriesByIdsRepository" diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts index 18131b87a05..21a78f650e9 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestEntriesByIds/decorators/GetLatestEntriesByIdsNotDeletedDecorator.ts @@ -9,7 +9,9 @@ import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; * This decorator wraps the GetLatestEntriesByIdsUseCase and filters out * entries marked as deleted (wbyDeleted flag). */ -class GetLatestEntriesByIdsNotDeletedDecoratorImpl implements GetLatestEntriesByIdsUseCase.Interface { +class GetLatestEntriesByIdsNotDeletedDecoratorImpl + implements GetLatestEntriesByIdsUseCase.Interface +{ constructor(private decoratee: GetLatestEntriesByIdsUseCase.Interface) {} async execute( diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts index ab7f1c97e63..dcda847355d 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/GetLatestRevisionByEntryIdRepository.ts @@ -2,7 +2,12 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestRevisionByEntryIdRepository as RepositoryAbstraction } from "./abstractions.js"; import { EntryNotFoundError, EntryPersistenceError } from "~/domain/contentEntry/errors.js"; -import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { EntryFromStorageTransform } from "~/legacy/abstractions.js"; @@ -21,7 +26,10 @@ class GetLatestRevisionByEntryIdRepositoryImpl implements RepositoryAbstraction. params: CmsEntryStorageOperationsGetLatestRevisionParams ): Promise, RepositoryAbstraction.Error>> { try { - const entry = await this.storageOperations.entries.getLatestRevisionByEntryId(model, params); + const entry = await this.storageOperations.entries.getLatestRevisionByEntryId( + model, + params + ); if (!entry) { return Result.fail(new EntryNotFoundError(params.id)); diff --git a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts index 2d9ec017efb..8277b3b8b1e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetLatestRevisionByEntryId/variations/GetLatestRevisionByEntryIdIncludingDeletedUseCase.ts @@ -2,12 +2,19 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { GetLatestRevisionByEntryIdIncludingDeletedUseCase as UseCaseAbstraction } from "../abstractions.js"; import { GetLatestRevisionByEntryIdBaseUseCase } from "../abstractions.js"; -import type { CmsEntry, CmsEntryValues, CmsModel, CmsEntryStorageOperationsGetLatestRevisionParams } from "~/types/index.js"; +import type { + CmsEntry, + CmsEntryValues, + CmsModel, + CmsEntryStorageOperationsGetLatestRevisionParams +} from "~/types/index.js"; /** * Returns any latest revision (deled and non-deleted) */ -class GetLatestRevisionByEntryIdIncludingDeletedUseCaseImpl implements UseCaseAbstraction.Interface { +class GetLatestRevisionByEntryIdIncludingDeletedUseCaseImpl + implements UseCaseAbstraction.Interface +{ constructor(private baseUseCase: GetLatestRevisionByEntryIdBaseUseCase.Interface) {} async execute( diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts index a9ff70f2b52..79a03a7c883 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/abstractions.ts @@ -20,7 +20,8 @@ export interface IGetPublishedEntriesByIdsUseCaseErrors { storage: EntryPersistenceError; } -type UseCaseError = IGetPublishedEntriesByIdsUseCaseErrors[keyof IGetPublishedEntriesByIdsUseCaseErrors]; +type UseCaseError = + IGetPublishedEntriesByIdsUseCaseErrors[keyof IGetPublishedEntriesByIdsUseCaseErrors]; export const GetPublishedEntriesByIdsUseCase = createAbstraction( "GetPublishedEntriesByIdsUseCase" @@ -45,11 +46,11 @@ export interface IGetPublishedEntriesByIdsRepositoryErrors { storage: EntryPersistenceError; } -type RepositoryError = IGetPublishedEntriesByIdsRepositoryErrors[keyof IGetPublishedEntriesByIdsRepositoryErrors]; +type RepositoryError = + IGetPublishedEntriesByIdsRepositoryErrors[keyof IGetPublishedEntriesByIdsRepositoryErrors]; -export const GetPublishedEntriesByIdsRepository = createAbstraction( - "GetPublishedEntriesByIdsRepository" -); +export const GetPublishedEntriesByIdsRepository = + createAbstraction("GetPublishedEntriesByIdsRepository"); export namespace GetPublishedEntriesByIdsRepository { export type Interface = IGetPublishedEntriesByIdsRepository; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts index af4036c4245..23896fd3d9a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedEntriesByIds/decorators/GetPublishedEntriesByIdsNotDeletedDecorator.ts @@ -9,7 +9,9 @@ import type { CmsEntry, CmsEntryValues, CmsModel } from "~/types/index.js"; * This decorator wraps the GetPublishedEntriesByIdsUseCase and filters out * entries marked as deleted (wbyDeleted flag). */ -class GetPublishedEntriesByIdsNotDeletedDecoratorImpl implements GetPublishedEntriesByIdsUseCase.Interface { +class GetPublishedEntriesByIdsNotDeletedDecoratorImpl + implements GetPublishedEntriesByIdsUseCase.Interface +{ constructor(private decoratee: GetPublishedEntriesByIdsUseCase.Interface) {} async execute( diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts index 763ba96bba4..089eb329c14 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionById/abstractions.ts @@ -18,9 +18,8 @@ export interface IGetRevisionByIdUseCaseErrors { type UseCaseError = IGetRevisionByIdUseCaseErrors[keyof IGetRevisionByIdUseCaseErrors]; -export const GetRevisionByIdUseCase = createAbstraction( - "GetRevisionByIdUseCase" -); +export const GetRevisionByIdUseCase = + createAbstraction("GetRevisionByIdUseCase"); export namespace GetRevisionByIdUseCase { export type Interface = IGetRevisionByIdUseCase; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts index 2fac1f8df31..1883e7901a8 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetRevisionsByEntryId/abstractions.ts @@ -45,7 +45,8 @@ export interface IGetRevisionsByEntryIdRepositoryErrors { storage: EntryPersistenceError; } -type RepositoryError = IGetRevisionsByEntryIdRepositoryErrors[keyof IGetRevisionsByEntryIdRepositoryErrors]; +type RepositoryError = + IGetRevisionsByEntryIdRepositoryErrors[keyof IGetRevisionsByEntryIdRepositoryErrors]; export const GetRevisionsByEntryIdRepository = createAbstraction( "GetRevisionsByEntryIdRepository" diff --git a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts index dca918f17a1..e6b2412d253 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetUniqueFieldValues/abstractions.ts @@ -33,8 +33,7 @@ export interface IGetUniqueFieldValuesUseCaseErrors { invalidWhere: InvalidWhereConditionError; } -type UseCaseError = - IGetUniqueFieldValuesUseCaseErrors[keyof IGetUniqueFieldValuesUseCaseErrors]; +type UseCaseError = IGetUniqueFieldValuesUseCaseErrors[keyof IGetUniqueFieldValuesUseCaseErrors]; export const GetUniqueFieldValuesUseCase = createAbstraction( "GetUniqueFieldValuesUseCase" diff --git a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts index 5e805d84fa8..75ecf9af19d 100644 --- a/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/ListEntries/abstractions.ts @@ -108,9 +108,8 @@ export interface IListEntriesRepositoryErrors { type RepositoryError = IListEntriesRepositoryErrors[keyof IListEntriesRepositoryErrors]; -export const ListEntriesRepository = createAbstraction( - "ListEntriesRepository" -); +export const ListEntriesRepository = + createAbstraction("ListEntriesRepository"); export namespace ListEntriesRepository { export type Interface = IListEntriesRepository; diff --git a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts index 4196cd5880c..e3b9c83c020 100644 --- a/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/MoveEntry/events.ts @@ -18,9 +18,8 @@ export class EntryBeforeMoveEvent extends DomainEvent { } } -export const EntryBeforeMoveHandler = createAbstraction>( - "EntryBeforeMoveHandler" -); +export const EntryBeforeMoveHandler = + createAbstraction>("EntryBeforeMoveHandler"); export namespace EntryBeforeMoveHandler { export type Interface = IEventHandler; @@ -38,9 +37,8 @@ export class EntryAfterMoveEvent extends DomainEvent { } } -export const EntryAfterMoveHandler = createAbstraction>( - "EntryAfterMoveHandler" -); +export const EntryAfterMoveHandler = + createAbstraction>("EntryAfterMoveHandler"); export namespace EntryAfterMoveHandler { export type Interface = IEventHandler; @@ -58,9 +56,8 @@ export class EntryMoveErrorEvent extends DomainEvent { } } -export const EntryMoveErrorHandler = createAbstraction>( - "EntryMoveErrorHandler" -); +export const EntryMoveErrorHandler = + createAbstraction>("EntryMoveErrorHandler"); export namespace EntryMoveErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts index b5d0d9a6942..ddef2304f1a 100644 --- a/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/PublishEntry/abstractions.ts @@ -69,9 +69,8 @@ export interface IPublishEntryRepositoryErrors { type RepositoryError = IPublishEntryRepositoryErrors[keyof IPublishEntryRepositoryErrors]; -export const PublishEntryRepository = createAbstraction( - "PublishEntryRepository" -); +export const PublishEntryRepository = + createAbstraction("PublishEntryRepository"); export namespace PublishEntryRepository { export type Interface = IPublishEntryRepository; diff --git a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts index c2a7927b613..e66807ca9c5 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RepublishEntry/abstractions.ts @@ -21,9 +21,8 @@ export interface IRepublishEntryUseCaseErrors { type UseCaseError = IRepublishEntryUseCaseErrors[keyof IRepublishEntryUseCaseErrors]; -export const RepublishEntryUseCase = createAbstraction( - "RepublishEntryUseCase" -); +export const RepublishEntryUseCase = + createAbstraction("RepublishEntryUseCase"); export namespace RepublishEntryUseCase { export type Interface = IRepublishEntryUseCase; diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts index 5f7ffb0e102..33c53061240 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/abstractions.ts @@ -65,7 +65,8 @@ export interface IRestoreEntryFromBinRepositoryErrors { storage: EntryPersistenceError; } -type RepositoryError = IRestoreEntryFromBinRepositoryErrors[keyof IRestoreEntryFromBinRepositoryErrors]; +type RepositoryError = + IRestoreEntryFromBinRepositoryErrors[keyof IRestoreEntryFromBinRepositoryErrors]; export const RestoreEntryFromBinRepository = createAbstraction( "RestoreEntryFromBinRepository" diff --git a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts index 59337d7b4a0..fb43608b9cb 100644 --- a/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/RestoreEntryFromBin/events.ts @@ -18,10 +18,9 @@ export class EntryBeforeRestoreFromBinEvent extends DomainEvent>( - "EntryBeforeRestoreFromBinHandler" - ); +export const EntryBeforeRestoreFromBinHandler = createAbstraction< + IEventHandler +>("EntryBeforeRestoreFromBinHandler"); export namespace EntryBeforeRestoreFromBinHandler { export type Interface = IEventHandler; @@ -39,10 +38,9 @@ export class EntryAfterRestoreFromBinEvent extends DomainEvent>( - "EntryAfterRestoreFromBinHandler" - ); +export const EntryAfterRestoreFromBinHandler = createAbstraction< + IEventHandler +>("EntryAfterRestoreFromBinHandler"); export namespace EntryAfterRestoreFromBinHandler { export type Interface = IEventHandler; @@ -60,10 +58,9 @@ export class EntryRestoreFromBinErrorEvent extends DomainEvent>( - "EntryRestoreFromBinErrorHandler" - ); +export const EntryRestoreFromBinErrorHandler = createAbstraction< + IEventHandler +>("EntryRestoreFromBinErrorHandler"); export namespace EntryRestoreFromBinErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts index 3effabcb676..9f3c9990ed1 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UnpublishEntry/abstractions.ts @@ -23,9 +23,8 @@ export interface IUnpublishEntryUseCaseErrors { type UseCaseError = IUnpublishEntryUseCaseErrors[keyof IUnpublishEntryUseCaseErrors]; -export const UnpublishEntryUseCase = createAbstraction( - "UnpublishEntryUseCase" -); +export const UnpublishEntryUseCase = + createAbstraction("UnpublishEntryUseCase"); export namespace UnpublishEntryUseCase { export type Interface = IUnpublishEntryUseCase; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts index fececaf03df..5514dc363cc 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/abstractions.ts @@ -59,9 +59,8 @@ export interface IUpdateEntryRepositoryErrors { type RepositoryError = IUpdateEntryRepositoryErrors[keyof IUpdateEntryRepositoryErrors]; -export const UpdateEntryRepository = createAbstraction( - "UpdateEntryRepository" -); +export const UpdateEntryRepository = + createAbstraction("UpdateEntryRepository"); export namespace UpdateEntryRepository { export type Interface = IUpdateEntryRepository; diff --git a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts index 4b835d093ad..1b84114790e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts +++ b/packages/api-headless-cms/src/features/contentEntry/UpdateEntry/events.ts @@ -51,9 +51,8 @@ export class EntryAfterUpdateEvent extends DomainEvent } } -export const EntryAfterUpdateHandler = createAbstraction>( - "EntryAfterUpdateHandler" -); +export const EntryAfterUpdateHandler = + createAbstraction>("EntryAfterUpdateHandler"); export namespace EntryAfterUpdateHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts index 9fb74489fe7..3f71116b222 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModel/abstractions.ts @@ -6,7 +6,8 @@ import { type ModelSlugTakenError, ModelNotAuthorizedError, type ModelValidationError, - type ModelPersistenceError, ModelAlreadyExistsError + type ModelPersistenceError, + ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, @@ -26,7 +27,7 @@ export interface ICreateModelUseCaseErrors { slugTaken: ModelSlugTakenError; alreadyExists: ModelAlreadyExistsError; persistence: ModelPersistenceError; - groupNotFound: GroupNotFoundError; // Reused from Group domain + groupNotFound: GroupNotFoundError; // Reused from Group domain groupNotAccessible: GroupNotAuthorizedError; // Reused from Group domain } diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts index 92fa76c6141..2225980379e 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts @@ -7,7 +7,8 @@ import { ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, - type ModelPersistenceError, ModelAlreadyExistsError + type ModelPersistenceError, + ModelAlreadyExistsError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, @@ -36,9 +37,8 @@ export interface ICreateModelFromUseCaseErrors { type UseCaseError = ICreateModelFromUseCaseErrors[keyof ICreateModelFromUseCaseErrors]; -export const CreateModelFromUseCase = createAbstraction( - "CreateModelFromUseCase" -); +export const CreateModelFromUseCase = + createAbstraction("CreateModelFromUseCase"); export namespace CreateModelFromUseCase { export type Interface = ICreateModelFromUseCase; diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts index 914f7b39bba..faaeff45540 100644 --- a/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/abstractions.ts @@ -21,9 +21,8 @@ export interface IInitializeModelUseCaseErrors { type UseCaseError = IInitializeModelUseCaseErrors[keyof IInitializeModelUseCaseErrors]; -export const InitializeModelUseCase = createAbstraction( - "InitializeModelUseCase" -); +export const InitializeModelUseCase = + createAbstraction("InitializeModelUseCase"); export namespace InitializeModelUseCase { export type Interface = IInitializeModelUseCase; diff --git a/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts b/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts index 5276f3d3e39..d277387f6f9 100644 --- a/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts +++ b/packages/api-headless-cms/src/features/contentModel/InitializeModel/events.ts @@ -24,9 +24,8 @@ export class ModelInitializeEvent extends DomainEvent { } } -export const ModelInitializeHandler = createAbstraction>( - "ModelInitializeHandler" -); +export const ModelInitializeHandler = + createAbstraction>("ModelInitializeHandler"); export namespace ModelInitializeHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts index b0610367499..5a6b7609e7d 100644 --- a/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/UpdateModel/abstractions.ts @@ -7,7 +7,8 @@ import { ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, - type ModelPersistenceError, ModelCannotUpdateCodeModelError + type ModelPersistenceError, + ModelCannotUpdateCodeModelError } from "~/domain/contentModel/errors.js"; import { type GroupNotFoundError, diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts index 8f5bcb406df..8e097563807 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/events.ts @@ -52,9 +52,8 @@ export class GroupAfterCreateEvent extends DomainEvent } } -export const GroupAfterCreateHandler = createAbstraction>( - "GroupAfterCreateHandler" -); +export const GroupAfterCreateHandler = + createAbstraction>("GroupAfterCreateHandler"); export namespace GroupAfterCreateHandler { export type Interface = IEventHandler; @@ -72,9 +71,8 @@ export class GroupCreateErrorEvent extends DomainEvent } } -export const GroupCreateErrorHandler = createAbstraction>( - "GroupCreateErrorHandler" -); +export const GroupCreateErrorHandler = + createAbstraction>("GroupCreateErrorHandler"); export namespace GroupCreateErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts index 840c7ee2082..41a234063a4 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/DeleteGroup/events.ts @@ -50,9 +50,8 @@ export class GroupAfterDeleteEvent extends DomainEvent } } -export const GroupAfterDeleteHandler = createAbstraction>( - "GroupAfterDeleteHandler" -); +export const GroupAfterDeleteHandler = + createAbstraction>("GroupAfterDeleteHandler"); export namespace GroupAfterDeleteHandler { export type Interface = IEventHandler; @@ -70,9 +69,8 @@ export class GroupDeleteErrorEvent extends DomainEvent } } -export const GroupDeleteErrorHandler = createAbstraction>( - "GroupDeleteErrorHandler" -); +export const GroupDeleteErrorHandler = + createAbstraction>("GroupDeleteErrorHandler"); export namespace GroupDeleteErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts index d5cefb3d550..284c6a937db 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/GetGroup/abstractions.ts @@ -1,7 +1,10 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; -import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { + GroupNotAuthorizedError, + type GroupNotFoundError +} from "~/domain/contentModelGroup/errors.js"; import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts index 4ae9f3b2411..3ec718b10cd 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/UpdateGroupUseCase.ts @@ -9,7 +9,10 @@ import { GroupAfterUpdateEvent } from "./events.js"; import { GroupUpdateErrorEvent } from "./events.js"; import { AccessControl } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { GroupNotAuthorizedError, GroupValidationError } from "~/domain/contentModelGroup/errors.js"; +import { + GroupNotAuthorizedError, + GroupValidationError +} from "~/domain/contentModelGroup/errors.js"; import { createZodError } from "@webiny/utils"; import { createGroupUpdateValidation } from "~/domain/contentModelGroup/validation.js"; import type { CmsGroup } from "~/types/index.js"; @@ -64,7 +67,9 @@ class UpdateGroupUseCaseImpl implements UseCaseAbstraction.Interface { const validationResult = await createGroupUpdateValidation().safeParseAsync(input); if (!validationResult.success) { const zodError = createZodError(validationResult.error); - return Result.fail(new GroupValidationError(zodError.message, zodError.data!.invalidFields)); + return Result.fail( + new GroupValidationError(zodError.message, zodError.data!.invalidFields) + ); } const data = validationResult.data; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts index 78259b1cfe6..bde126a71a8 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/abstractions.ts @@ -2,7 +2,10 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsGroup } from "~/types/index.js"; import type { CmsGroupUpdateInput } from "~/types/index.js"; -import { GroupNotAuthorizedError, type GroupNotFoundError } from "~/domain/contentModelGroup/errors.js"; +import { + GroupNotAuthorizedError, + type GroupNotFoundError +} from "~/domain/contentModelGroup/errors.js"; import type { GroupValidationError } from "~/domain/contentModelGroup/errors.js"; import type { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import type { GroupCannotUpdateCodeDefinedError } from "~/domain/contentModelGroup/errors.js"; @@ -44,9 +47,8 @@ export interface IUpdateGroupRepositoryErrors { type RepositoryError = IUpdateGroupRepositoryErrors[keyof IUpdateGroupRepositoryErrors]; -export const UpdateGroupRepository = createAbstraction( - "UpdateGroupRepository" -); +export const UpdateGroupRepository = + createAbstraction("UpdateGroupRepository"); export namespace UpdateGroupRepository { export type Interface = IUpdateGroupRepository; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts index a1dbec1149f..6f4bf4d495f 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/UpdateGroup/events.ts @@ -55,9 +55,8 @@ export class GroupAfterUpdateEvent extends DomainEvent } } -export const GroupAfterUpdateHandler = createAbstraction>( - "GroupAfterUpdateHandler" -); +export const GroupAfterUpdateHandler = + createAbstraction>("GroupAfterUpdateHandler"); export namespace GroupAfterUpdateHandler { export type Interface = IEventHandler; @@ -75,9 +74,8 @@ export class GroupUpdateErrorEvent extends DomainEvent } } -export const GroupUpdateErrorHandler = createAbstraction>( - "GroupUpdateErrorHandler" -); +export const GroupUpdateErrorHandler = + createAbstraction>("GroupUpdateErrorHandler"); export namespace GroupUpdateErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts index 0dead3f89af..b1ebf0b61b0 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/shared/PluginGroupsProvider.ts @@ -57,7 +57,7 @@ class PluginGroupsProviderImpl implements ProviderAbstraction.Interface { .map(plugin => { return { ...plugin.contentModelGroup, - tenant: tenant.id, + tenant: tenant.id }; }); diff --git a/packages/api-headless-cms/src/graphqlFields/ref.ts b/packages/api-headless-cms/src/graphqlFields/ref.ts index f7cce319f78..7c3255259e0 100644 --- a/packages/api-headless-cms/src/graphqlFields/ref.ts +++ b/packages/api-headless-cms/src/graphqlFields/ref.ts @@ -215,7 +215,10 @@ export const createRefField = (): CmsModelFieldToGraphQLPlugin => { } // `preview` and `manage` with `latest` data else { - const latestByIsResult = await getLatestByIds.execute(model, idList); + const latestByIsResult = await getLatestByIds.execute( + model, + idList + ); entries = latestByIsResult.value; } return appendTypename(entries, modelIdToTypeName.get(modelId)); @@ -255,7 +258,9 @@ export const createRefField = (): CmsModelFieldToGraphQLPlugin => { } // `preview` API works with `latest` data else { - const latestByIdsResult = await getLatestByIds.execute(model, [value.entryId]); + const latestByIdsResult = await getLatestByIds.execute(model, [ + value.entryId + ]); revisions = latestByIdsResult.value; } diff --git a/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts b/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts index c6debc5521e..1b54c679a86 100644 --- a/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts +++ b/packages/api-headless-cms/src/plugins/CmsGroupPlugin.ts @@ -1,8 +1,7 @@ import { Plugin } from "@webiny/plugins"; import type { CmsGroup as BaseCmsGroup } from "~/types/index.js"; -export interface CmsGroupInput - extends Omit { +export interface CmsGroupInput extends Omit { tenant?: string; locale?: string; } diff --git a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts index ee61bb00477..42268211d74 100644 --- a/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts +++ b/packages/api-headless-cms/src/plugins/CmsModelPlugin.ts @@ -86,8 +86,7 @@ export interface CmsPrivateModelFull export type CmsModelInput = CmsApiModel | CmsPrivateModel | CmsApiModelFull | CmsPrivateModelFull; -export interface CmsModelPluginModel - extends Omit { +export interface CmsModelPluginModel extends Omit { locale?: string; tenant?: string; } diff --git a/packages/api-headless-cms/src/types/context.ts b/packages/api-headless-cms/src/types/context.ts index 2e6ea127de6..e3d87961260 100644 --- a/packages/api-headless-cms/src/types/context.ts +++ b/packages/api-headless-cms/src/types/context.ts @@ -79,7 +79,10 @@ export interface CmsEntryContext { /** * Get the entry for a model by a given ID. */ - getEntryById(model: CmsModel, revision: string): Promise>; + getEntryById( + model: CmsModel, + revision: string + ): Promise>; /** * List entries for a model. Internal method used by get, listLatest and listPublished. */ diff --git a/packages/api-record-locking/package.json b/packages/api-record-locking/package.json index 3d8cccb9799..17b5283a381 100644 --- a/packages/api-record-locking/package.json +++ b/packages/api-record-locking/package.json @@ -16,19 +16,18 @@ "@webiny/api": "0.0.0", "@webiny/api-headless-cms": "0.0.0", "@webiny/api-websockets": "0.0.0", - "@webiny/error": "0.0.0", "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/handler-graphql": "0.0.0", "@webiny/plugins": "0.0.0", - "@webiny/pubsub": "0.0.0", "@webiny/utils": "0.0.0" }, "devDependencies": { "@webiny/api-core": "0.0.0", "@webiny/build-tools": "0.0.0", "@webiny/project-utils": "0.0.0", + "@webiny/wcp": "0.0.0", "graphql": "^16.12.0", "rimraf": "^6.0.1", "type-fest": "^5.2.0", diff --git a/packages/api-record-locking/src/domain/errors.ts b/packages/api-record-locking/src/domain/errors.ts index 7eb2cf48f33..c7ecc02953d 100644 --- a/packages/api-record-locking/src/domain/errors.ts +++ b/packages/api-record-locking/src/domain/errors.ts @@ -93,7 +93,11 @@ export class EntryNotLockedError extends BaseError<{ id: string; type: string }> } } -export class UnlockRequestAlreadySentError extends BaseError<{ id: string; type: string; identityId: string }> { +export class UnlockRequestAlreadySentError extends BaseError<{ + id: string; + type: string; + identityId: string; +}> { override readonly code = "RecordLocking/Entry/UnlockRequestAlreadySentError" as const; constructor(data: { id: string; type: string; identityId: string }) { diff --git a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts index d6388ef26af..8e24f678fd9 100644 --- a/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts +++ b/packages/api-record-locking/src/features/GetLockedEntryLockRecord/abstractions.ts @@ -23,9 +23,12 @@ export interface IGetLockedEntryLockRecordUseCaseErrors { notFound: LockRecordNotFoundError; } -type UseCaseError = IGetLockedEntryLockRecordUseCaseErrors[keyof IGetLockedEntryLockRecordUseCaseErrors]; +type UseCaseError = + IGetLockedEntryLockRecordUseCaseErrors[keyof IGetLockedEntryLockRecordUseCaseErrors]; -export const GetLockedEntryLockRecordUseCase = createAbstraction("GetLockedEntryLockRecordUseCase"); +export const GetLockedEntryLockRecordUseCase = createAbstraction( + "GetLockedEntryLockRecordUseCase" +); export namespace GetLockedEntryLockRecordUseCase { export type Interface = IGetLockedEntryLockRecordUseCase; diff --git a/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts index 678244a5413..2d591b2c5f3 100644 --- a/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts +++ b/packages/api-record-locking/src/features/IsEntryLocked/IsEntryLockedUseCase.ts @@ -1,8 +1,5 @@ import { Result } from "@webiny/feature/api"; -import { - IsEntryLockedUseCase as UseCaseAbstraction, - IsEntryLockedInput -} from "./abstractions.js"; +import { IsEntryLockedUseCase as UseCaseAbstraction, IsEntryLockedInput } from "./abstractions.js"; import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { LockRecordNotFoundError } from "~/domain/errors.js"; diff --git a/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts b/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts index e0db506363f..7e20a09a572 100644 --- a/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts +++ b/packages/api-record-locking/src/features/IsEntryLocked/abstractions.ts @@ -23,7 +23,8 @@ export interface IIsEntryLockedUseCaseErrors { type UseCaseError = IIsEntryLockedUseCaseErrors[keyof IIsEntryLockedUseCaseErrors]; -export const IsEntryLockedUseCase = createAbstraction("IsEntryLockedUseCase"); +export const IsEntryLockedUseCase = + createAbstraction("IsEntryLockedUseCase"); export namespace IsEntryLockedUseCase { export type Interface = IIsEntryLockedUseCase; diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts index 240dc384ec6..f531633ec94 100644 --- a/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts +++ b/packages/api-record-locking/src/features/ListAllLockRecords/ListAllLockRecordsUseCase.ts @@ -9,7 +9,9 @@ import { class ListAllLockRecordsUseCaseImpl implements UseCaseAbstraction.Interface { constructor(private repository: ListAllLockRecordsRepository.Interface) {} - async execute(input?: ListAllLockRecordsInput): Promise> { + async execute( + input?: ListAllLockRecordsInput + ): Promise> { return await this.repository.execute(input); } } diff --git a/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts b/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts index 19abf5f95f4..06c44e07ab8 100644 --- a/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts +++ b/packages/api-record-locking/src/features/ListAllLockRecords/abstractions.ts @@ -5,7 +5,10 @@ import type { LockRecordPersistenceError } from "~/domain/errors.js"; import type { CmsEntryListParams, CmsEntryMeta } from "@webiny/api-headless-cms/types"; // Input/Output types -export type ListAllLockRecordsInput = Pick; +export type ListAllLockRecordsInput = Pick< + CmsEntryListParams, + "where" | "limit" | "sort" | "after" +>; export interface ListAllLockRecordsOutput { items: ILockRecord[]; @@ -16,7 +19,9 @@ export interface ListAllLockRecordsOutput { * ListAllLockRecords Use Case - Lists all lock records without filtering */ export interface IListAllLockRecordsUseCase { - execute(input?: ListAllLockRecordsInput): Promise>; + execute( + input?: ListAllLockRecordsInput + ): Promise>; } export interface IListAllLockRecordsUseCaseErrors { @@ -25,7 +30,9 @@ export interface IListAllLockRecordsUseCaseErrors { type UseCaseError = IListAllLockRecordsUseCaseErrors[keyof IListAllLockRecordsUseCaseErrors]; -export const ListAllLockRecordsUseCase = createAbstraction("ListAllLockRecordsUseCase"); +export const ListAllLockRecordsUseCase = createAbstraction( + "ListAllLockRecordsUseCase" +); export namespace ListAllLockRecordsUseCase { export type Interface = IListAllLockRecordsUseCase; @@ -36,16 +43,21 @@ export namespace ListAllLockRecordsUseCase { * ListAllLockRecordsRepository - Fetches all lock records from storage */ export interface IListAllLockRecordsRepository { - execute(input?: ListAllLockRecordsInput): Promise>; + execute( + input?: ListAllLockRecordsInput + ): Promise>; } export interface IListAllLockRecordsRepositoryErrors { persistence: LockRecordPersistenceError; } -type RepositoryError = IListAllLockRecordsRepositoryErrors[keyof IListAllLockRecordsRepositoryErrors]; +type RepositoryError = + IListAllLockRecordsRepositoryErrors[keyof IListAllLockRecordsRepositoryErrors]; -export const ListAllLockRecordsRepository = createAbstraction("ListAllLockRecordsRepository"); +export const ListAllLockRecordsRepository = createAbstraction( + "ListAllLockRecordsRepository" +); export namespace ListAllLockRecordsRepository { export type Interface = IListAllLockRecordsRepository; diff --git a/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts index f3c08a05035..eae9af20c0f 100644 --- a/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts +++ b/packages/api-record-locking/src/features/ListLockRecords/ListLockRecordsUseCase.ts @@ -15,7 +15,9 @@ class ListLockRecordsUseCaseImpl implements UseCaseAbstraction.Interface { private config: RecordLockingConfig.Interface ) {} - async execute(input?: ListLockRecordsInput): Promise> { + async execute( + input?: ListLockRecordsInput + ): Promise> { const identity = this.identityContext.getIdentity(); // Filter out expired locks and exclude current user's locks diff --git a/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts b/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts index a5404228340..09ce366b4eb 100644 --- a/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts +++ b/packages/api-record-locking/src/features/ListLockRecords/abstractions.ts @@ -25,7 +25,8 @@ export interface IListLockRecordsUseCaseErrors { type UseCaseError = IListLockRecordsUseCaseErrors[keyof IListLockRecordsUseCaseErrors]; -export const ListLockRecordsUseCase = createAbstraction("ListLockRecordsUseCase"); +export const ListLockRecordsUseCase = + createAbstraction("ListLockRecordsUseCase"); export namespace ListLockRecordsUseCase { export type Interface = IListLockRecordsUseCase; @@ -45,7 +46,9 @@ export interface IListLockRecordsRepositoryErrors { type RepositoryError = IListLockRecordsRepositoryErrors[keyof IListLockRecordsRepositoryErrors]; -export const ListLockRecordsRepository = createAbstraction("ListLockRecordsRepository"); +export const ListLockRecordsRepository = createAbstraction( + "ListLockRecordsRepository" +); export namespace ListLockRecordsRepository { export type Interface = IListLockRecordsRepository; diff --git a/packages/api-record-locking/src/features/LockEntry/abstractions.ts b/packages/api-record-locking/src/features/LockEntry/abstractions.ts index c9d723275e8..b089a7068b1 100644 --- a/packages/api-record-locking/src/features/LockEntry/abstractions.ts +++ b/packages/api-record-locking/src/features/LockEntry/abstractions.ts @@ -2,7 +2,11 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { ILockRecord } from "~/domain/LockRecord.js"; import type { LockRecordEntryType } from "~/domain/types.js"; -import type { EntryAlreadyLockedError, LockEntryError, LockRecordPersistenceError } from "~/domain/errors.js"; +import type { + EntryAlreadyLockedError, + LockEntryError, + LockRecordPersistenceError +} from "~/domain/errors.js"; // Input types export interface LockEntryInput { diff --git a/packages/api-record-locking/src/features/LockEntry/events.ts b/packages/api-record-locking/src/features/LockEntry/events.ts index b706c5fa9b1..63b036f05e7 100644 --- a/packages/api-record-locking/src/features/LockEntry/events.ts +++ b/packages/api-record-locking/src/features/LockEntry/events.ts @@ -21,9 +21,8 @@ export class EntryBeforeLockEvent extends DomainEvent { } } -export const EntryBeforeLockHandler = createAbstraction>( - "EntryBeforeLockHandler" -); +export const EntryBeforeLockHandler = + createAbstraction>("EntryBeforeLockHandler"); export namespace EntryBeforeLockHandler { export type Interface = IEventHandler; @@ -48,9 +47,8 @@ export class EntryAfterLockEvent extends DomainEvent { } } -export const EntryAfterLockHandler = createAbstraction>( - "EntryAfterLockHandler" -); +export const EntryAfterLockHandler = + createAbstraction>("EntryAfterLockHandler"); export namespace EntryAfterLockHandler { export type Interface = IEventHandler; @@ -75,9 +73,8 @@ export class EntryLockErrorEvent extends DomainEvent { } } -export const EntryLockErrorHandler = createAbstraction>( - "EntryLockErrorHandler" -); +export const EntryLockErrorHandler = + createAbstraction>("EntryLockErrorHandler"); export namespace EntryLockErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-record-locking/src/features/UnlockEntry/events.ts b/packages/api-record-locking/src/features/UnlockEntry/events.ts index ac05915710b..14686378665 100644 --- a/packages/api-record-locking/src/features/UnlockEntry/events.ts +++ b/packages/api-record-locking/src/features/UnlockEntry/events.ts @@ -49,9 +49,8 @@ export class EntryAfterUnlockEvent extends DomainEvent } } -export const EntryAfterUnlockHandler = createAbstraction>( - "EntryAfterUnlockHandler" -); +export const EntryAfterUnlockHandler = + createAbstraction>("EntryAfterUnlockHandler"); export namespace EntryAfterUnlockHandler { export type Interface = IEventHandler; @@ -76,9 +75,8 @@ export class EntryUnlockErrorEvent extends DomainEvent } } -export const EntryUnlockErrorHandler = createAbstraction>( - "EntryUnlockErrorHandler" -); +export const EntryUnlockErrorHandler = + createAbstraction>("EntryUnlockErrorHandler"); export namespace EntryUnlockErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts index b17d475b146..c580dd72dd9 100644 --- a/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/abstractions.ts @@ -31,7 +31,9 @@ export interface IUnlockEntryRequestUseCaseErrors { type UseCaseError = IUnlockEntryRequestUseCaseErrors[keyof IUnlockEntryRequestUseCaseErrors]; -export const UnlockEntryRequestUseCase = createAbstraction("UnlockEntryRequestUseCase"); +export const UnlockEntryRequestUseCase = createAbstraction( + "UnlockEntryRequestUseCase" +); export namespace UnlockEntryRequestUseCase { export type Interface = IUnlockEntryRequestUseCase; @@ -49,9 +51,12 @@ export interface IUnlockEntryRequestRepositoryErrors { persistence: UnlockEntryRequestError; } -type RepositoryError = IUnlockEntryRequestRepositoryErrors[keyof IUnlockEntryRequestRepositoryErrors]; +type RepositoryError = + IUnlockEntryRequestRepositoryErrors[keyof IUnlockEntryRequestRepositoryErrors]; -export const UnlockEntryRequestRepository = createAbstraction("UnlockEntryRequestRepository"); +export const UnlockEntryRequestRepository = createAbstraction( + "UnlockEntryRequestRepository" +); export namespace UnlockEntryRequestRepository { export type Interface = IUnlockEntryRequestRepository; diff --git a/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts b/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts index 096a629e669..4c4f366d8fd 100644 --- a/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts +++ b/packages/api-record-locking/src/features/UnlockEntryRequest/events.ts @@ -21,9 +21,9 @@ export class EntryBeforeUnlockRequestEvent extends DomainEvent>( - "EntryBeforeUnlockRequestHandler" -); +export const EntryBeforeUnlockRequestHandler = createAbstraction< + IEventHandler +>("EntryBeforeUnlockRequestHandler"); export namespace EntryBeforeUnlockRequestHandler { export type Interface = IEventHandler; @@ -48,9 +48,9 @@ export class EntryAfterUnlockRequestEvent extends DomainEvent>( - "EntryAfterUnlockRequestHandler" -); +export const EntryAfterUnlockRequestHandler = createAbstraction< + IEventHandler +>("EntryAfterUnlockRequestHandler"); export namespace EntryAfterUnlockRequestHandler { export type Interface = IEventHandler; @@ -75,9 +75,9 @@ export class EntryUnlockRequestErrorEvent extends DomainEvent>( - "EntryUnlockRequestErrorHandler" -); +export const EntryUnlockRequestErrorHandler = createAbstraction< + IEventHandler +>("EntryUnlockRequestErrorHandler"); export namespace EntryUnlockRequestErrorHandler { export type Interface = IEventHandler; diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts index 5427cfdafc5..ce8144cc7f3 100644 --- a/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts +++ b/packages/api-record-locking/src/features/UpdateEntryLock/UpdateEntryLockUseCase.ts @@ -8,7 +8,11 @@ import { GetLockRecordUseCase } from "../GetLockRecord/abstractions.js"; import { LockEntryUseCase } from "../LockEntry/abstractions.js"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import type { ILockRecord } from "~/domain/LockRecord.js"; -import { LockRecordNotFoundError, IdentityMismatchError, UpdateEntryLockError } from "~/domain/errors.js"; +import { + LockRecordNotFoundError, + IdentityMismatchError, + UpdateEntryLockError +} from "~/domain/errors.js"; class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { constructor( @@ -18,7 +22,9 @@ class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { private identityContext: IdentityContext.Interface ) {} - async execute(input: UpdateEntryLockInput): Promise> { + async execute( + input: UpdateEntryLockInput + ): Promise> { // Try to get existing lock record const recordResult = await this.getLockRecord.execute(input); @@ -68,5 +74,10 @@ class UpdateEntryLockUseCaseImpl implements UseCaseAbstraction.Interface { export const UpdateEntryLockUseCase = UseCaseAbstraction.createImplementation({ implementation: UpdateEntryLockUseCaseImpl, - dependencies: [GetLockRecordUseCase, LockEntryUseCase, UpdateEntryLockRepository, IdentityContext] + dependencies: [ + GetLockRecordUseCase, + LockEntryUseCase, + UpdateEntryLockRepository, + IdentityContext + ] }); diff --git a/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts index 55bc69ec4ae..dc03cf011e2 100644 --- a/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts +++ b/packages/api-record-locking/src/features/UpdateEntryLock/abstractions.ts @@ -2,7 +2,12 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { ILockRecord } from "~/domain/LockRecord.js"; import type { LockRecordEntryType } from "~/domain/types.js"; -import type { LockRecordNotFoundError, LockRecordPersistenceError, IdentityMismatchError, UpdateEntryLockError } from "~/domain/errors.js"; +import type { + LockRecordNotFoundError, + LockRecordPersistenceError, + IdentityMismatchError, + UpdateEntryLockError +} from "~/domain/errors.js"; // Input types export interface UpdateEntryLockInput { @@ -26,7 +31,8 @@ export interface IUpdateEntryLockUseCaseErrors { type UseCaseError = IUpdateEntryLockUseCaseErrors[keyof IUpdateEntryLockUseCaseErrors]; -export const UpdateEntryLockUseCase = createAbstraction("UpdateEntryLockUseCase"); +export const UpdateEntryLockUseCase = + createAbstraction("UpdateEntryLockUseCase"); export namespace UpdateEntryLockUseCase { export type Interface = IUpdateEntryLockUseCase; @@ -37,7 +43,10 @@ export namespace UpdateEntryLockUseCase { * UpdateEntryLockRepository - Updates lock record in storage */ export interface IUpdateEntryLockRepository { - update(lockRecordId: string, updateOwner: boolean): Promise>; + update( + lockRecordId: string, + updateOwner: boolean + ): Promise>; } export interface IUpdateEntryLockRepositoryErrors { @@ -46,7 +55,9 @@ export interface IUpdateEntryLockRepositoryErrors { type RepositoryError = IUpdateEntryLockRepositoryErrors[keyof IUpdateEntryLockRepositoryErrors]; -export const UpdateEntryLockRepository = createAbstraction("UpdateEntryLockRepository"); +export const UpdateEntryLockRepository = createAbstraction( + "UpdateEntryLockRepository" +); export namespace UpdateEntryLockRepository { export type Interface = IUpdateEntryLockRepository; diff --git a/packages/api-record-locking/tsconfig.build.json b/packages/api-record-locking/tsconfig.build.json index c91653e25bf..42c56b14ee8 100644 --- a/packages/api-record-locking/tsconfig.build.json +++ b/packages/api-record-locking/tsconfig.build.json @@ -5,15 +5,14 @@ { "path": "../api/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../api-websockets/tsconfig.build.json" }, - { "path": "../error/tsconfig.build.json" }, { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-graphql/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, - { "path": "../pubsub/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" }, - { "path": "../api-core/tsconfig.build.json" } + { "path": "../api-core/tsconfig.build.json" }, + { "path": "../wcp/tsconfig.build.json" } ], "compilerOptions": { "rootDir": "./src", @@ -26,13 +25,8 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], "@webiny/feature/admin": ["../feature/src/admin/index.js"], "@webiny/feature/*": ["../feature/src/*"], @@ -45,8 +39,6 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], "@webiny/utils/*": ["../utils/src/*"], "@webiny/utils": ["../utils/src"], "@webiny/api-core/features/EventPublisher": [ @@ -174,7 +166,9 @@ "../api-core/src/features/users/ExternalIdpUserSync/index.js" ], "@webiny/api-core/*": ["../api-core/src/*"], - "@webiny/api-core": ["../api-core/src"] + "@webiny/api-core": ["../api-core/src"], + "@webiny/wcp/*": ["../wcp/src/*"], + "@webiny/wcp": ["../wcp/src"] }, "baseUrl": "." } diff --git a/packages/api-record-locking/tsconfig.json b/packages/api-record-locking/tsconfig.json index eebdc8676fe..d314c8ead54 100644 --- a/packages/api-record-locking/tsconfig.json +++ b/packages/api-record-locking/tsconfig.json @@ -5,15 +5,14 @@ { "path": "../api" }, { "path": "../api-headless-cms" }, { "path": "../api-websockets" }, - { "path": "../error" }, { "path": "../feature" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../handler-graphql" }, { "path": "../plugins" }, - { "path": "../pubsub" }, { "path": "../utils" }, - { "path": "../api-core" } + { "path": "../api-core" }, + { "path": "../wcp" } ], "compilerOptions": { "rootDirs": ["./src", "./__tests__"], @@ -26,13 +25,8 @@ "@webiny/api": ["../api/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], "@webiny/api-headless-cms": ["../api-headless-cms/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], - "@webiny/error/*": ["../error/src/*"], - "@webiny/error": ["../error/src"], "@webiny/feature/api": ["../feature/src/api/index.js"], "@webiny/feature/admin": ["../feature/src/admin/index.js"], "@webiny/feature/*": ["../feature/src/*"], @@ -45,8 +39,6 @@ "@webiny/handler-graphql": ["../handler-graphql/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], - "@webiny/pubsub/*": ["../pubsub/src/*"], - "@webiny/pubsub": ["../pubsub/src"], "@webiny/utils/*": ["../utils/src/*"], "@webiny/utils": ["../utils/src"], "@webiny/api-core/features/EventPublisher": [ @@ -174,7 +166,9 @@ "../api-core/src/features/users/ExternalIdpUserSync/index.js" ], "@webiny/api-core/*": ["../api-core/src/*"], - "@webiny/api-core": ["../api-core/src"] + "@webiny/api-core": ["../api-core/src"], + "@webiny/wcp/*": ["../wcp/src/*"], + "@webiny/wcp": ["../wcp/src"] }, "baseUrl": "." } diff --git a/packages/api-scheduler/__tests__/Scheduler.test.ts b/packages/api-scheduler/__tests__/Scheduler.test.ts index 7d5cd1f83c3..4036d51db57 100644 --- a/packages/api-scheduler/__tests__/Scheduler.test.ts +++ b/packages/api-scheduler/__tests__/Scheduler.test.ts @@ -247,7 +247,9 @@ describe("Scheduler", () => { expect(scheduleResult1.isOk()).toBe(true); expect(scheduleResult2.isOk()).toBe(true); - const scheduledActionsResult = await listScheduledActions.execute({ where: { namespace, targetId } }); + const scheduledActionsResult = await listScheduledActions.execute({ + where: { namespace, targetId } + }); expect(scheduledActionsResult.isOk()).toBe(true); const scheduledActions = scheduledActionsResult.value.items; diff --git a/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts b/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts index 701d2869300..d96fd17d3b0 100644 --- a/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts +++ b/packages/api-scheduler/src/features/ExecuteScheduledAction/abstractions.ts @@ -1,9 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import { - ScheduledActionNotFoundError, - ScheduledActionPersistenceError -} from "~/domain/errors.js"; +import { ScheduledActionNotFoundError, ScheduledActionPersistenceError } from "~/domain/errors.js"; /** * ExecuteScheduledActionUseCase - Execute a scheduled action @@ -26,7 +23,8 @@ export interface IExecuteScheduledActionErrors { executionFailed: ExecutionFailedError; } -type ExecuteScheduledActionError = IExecuteScheduledActionErrors[keyof IExecuteScheduledActionErrors]; +type ExecuteScheduledActionError = + IExecuteScheduledActionErrors[keyof IExecuteScheduledActionErrors]; export interface IExecuteScheduledActionUseCase { execute(scheduleId: string): Promise>; @@ -48,9 +46,7 @@ export class HandlerNotFoundError extends Error { readonly code = "Scheduler/Handler/NotFound" as const; constructor(namespace: string, actionType: string) { - super( - `No handler registered for namespace "${namespace}" and actionType "${actionType}"` - ); + super(`No handler registered for namespace "${namespace}" and actionType "${actionType}"`); this.name = "HandlerNotFoundError"; } } @@ -61,7 +57,10 @@ export class HandlerNotFoundError extends Error { export class ExecutionFailedError extends Error { readonly code = "Scheduler/Execution/Failed" as const; - constructor(message: string, public readonly originalError?: Error) { + constructor( + message: string, + public readonly originalError?: Error + ) { super(message); this.name = "ExecutionFailedError"; } diff --git a/packages/api-security-cognito/__tests__/tenancySecurity.ts b/packages/api-security-cognito/__tests__/tenancySecurity.ts index 627bc388ba6..f42b96a0275 100644 --- a/packages/api-security-cognito/__tests__/tenancySecurity.ts +++ b/packages/api-security-cognito/__tests__/tenancySecurity.ts @@ -25,7 +25,7 @@ export const createTenancyAndSecurity = ({ fullAccess, identity }: Config = {}) }, description: "", createdOn: new Date().toISOString(), - savedOn: new Date().toISOString(), + savedOn: new Date().toISOString() }); context.security.addAuthenticator(async () => { diff --git a/packages/api-websockets/package.json b/packages/api-websockets/package.json index 88f5e730a0b..f23dcfc3b24 100644 --- a/packages/api-websockets/package.json +++ b/packages/api-websockets/package.json @@ -22,6 +22,7 @@ "@webiny/aws-sdk": "0.0.0", "@webiny/db-dynamodb": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/feature": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/plugins": "0.0.0", diff --git a/packages/api-websockets/tsconfig.build.json b/packages/api-websockets/tsconfig.build.json index 8e1f2c85f4b..14434d2430b 100644 --- a/packages/api-websockets/tsconfig.build.json +++ b/packages/api-websockets/tsconfig.build.json @@ -7,6 +7,7 @@ { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../db-dynamodb/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../feature/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, @@ -156,6 +157,10 @@ "@webiny/db-dynamodb": ["../db-dynamodb/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/api-websockets/tsconfig.json b/packages/api-websockets/tsconfig.json index e3b07ebc04c..fd96724009c 100644 --- a/packages/api-websockets/tsconfig.json +++ b/packages/api-websockets/tsconfig.json @@ -7,6 +7,7 @@ { "path": "../aws-sdk" }, { "path": "../db-dynamodb" }, { "path": "../error" }, + { "path": "../feature" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../plugins" }, @@ -156,6 +157,10 @@ "@webiny/db-dynamodb": ["../db-dynamodb/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature/api": ["../feature/src/api/index.js"], + "@webiny/feature/admin": ["../feature/src/admin/index.js"], + "@webiny/feature/*": ["../feature/src/*"], + "@webiny/feature": ["../feature/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/project-aws/package.json b/packages/project-aws/package.json index 7b14a1000b3..a1fc1725ff7 100644 --- a/packages/project-aws/package.json +++ b/packages/project-aws/package.json @@ -40,6 +40,7 @@ "@webiny/api-log": "0.0.0", "@webiny/api-mailer": "0.0.0", "@webiny/api-record-locking": "0.0.0", + "@webiny/api-scheduler": "0.0.0", "@webiny/api-security-cognito": "0.0.0", "@webiny/api-website-builder": "0.0.0", "@webiny/api-websockets": "0.0.0", diff --git a/packages/project-aws/tsconfig.build.json b/packages/project-aws/tsconfig.build.json index acc3ebd64f6..667ad837242 100644 --- a/packages/project-aws/tsconfig.build.json +++ b/packages/project-aws/tsconfig.build.json @@ -24,6 +24,7 @@ { "path": "../api-log/tsconfig.build.json" }, { "path": "../api-mailer/tsconfig.build.json" }, { "path": "../api-record-locking/tsconfig.build.json" }, + { "path": "../api-scheduler/tsconfig.build.json" }, { "path": "../api-security-cognito/tsconfig.build.json" }, { "path": "../api-website-builder/tsconfig.build.json" }, { "path": "../api-websockets/tsconfig.build.json" }, @@ -244,13 +245,27 @@ "@webiny/api-mailer": ["../api-mailer/src"], "@webiny/api-record-locking/*": ["../api-record-locking/src/*"], "@webiny/api-record-locking": ["../api-record-locking/src"], + "@webiny/api-scheduler/features/ScheduleAction": [ + "../api-scheduler/src/features/ScheduleAction/index.js" + ], + "@webiny/api-scheduler/features/GetScheduledAction": [ + "../api-scheduler/src/features/GetScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ListScheduledActions": [ + "../api-scheduler/src/features/ListScheduledActions/index.js" + ], + "@webiny/api-scheduler/features/CancelScheduledAction": [ + "../api-scheduler/src/features/CancelScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ExecuteScheduledAction": [ + "../api-scheduler/src/features/ExecuteScheduledAction/index.js" + ], + "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], + "@webiny/api-scheduler": ["../api-scheduler/src"], "@webiny/api-security-cognito/*": ["../api-security-cognito/src/*"], "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/api-workflows/*": ["../api-workflows/src/*"], diff --git a/packages/project-aws/tsconfig.json b/packages/project-aws/tsconfig.json index adbd79b1dd5..337a14f0348 100644 --- a/packages/project-aws/tsconfig.json +++ b/packages/project-aws/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../api-log" }, { "path": "../api-mailer" }, { "path": "../api-record-locking" }, + { "path": "../api-scheduler" }, { "path": "../api-security-cognito" }, { "path": "../api-website-builder" }, { "path": "../api-websockets" }, @@ -244,13 +245,27 @@ "@webiny/api-mailer": ["../api-mailer/src"], "@webiny/api-record-locking/*": ["../api-record-locking/src/*"], "@webiny/api-record-locking": ["../api-record-locking/src"], + "@webiny/api-scheduler/features/ScheduleAction": [ + "../api-scheduler/src/features/ScheduleAction/index.js" + ], + "@webiny/api-scheduler/features/GetScheduledAction": [ + "../api-scheduler/src/features/GetScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ListScheduledActions": [ + "../api-scheduler/src/features/ListScheduledActions/index.js" + ], + "@webiny/api-scheduler/features/CancelScheduledAction": [ + "../api-scheduler/src/features/CancelScheduledAction/index.js" + ], + "@webiny/api-scheduler/features/ExecuteScheduledAction": [ + "../api-scheduler/src/features/ExecuteScheduledAction/index.js" + ], + "@webiny/api-scheduler/*": ["../api-scheduler/src/*"], + "@webiny/api-scheduler": ["../api-scheduler/src"], "@webiny/api-security-cognito/*": ["../api-security-cognito/src/*"], "@webiny/api-security-cognito": ["../api-security-cognito/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/api-workflows/*": ["../api-workflows/src/*"], diff --git a/packages/tasks/src/domain/errors.ts b/packages/tasks/src/domain/errors.ts index 5873b123a15..d912ed1fdc7 100644 --- a/packages/tasks/src/domain/errors.ts +++ b/packages/tasks/src/domain/errors.ts @@ -13,7 +13,6 @@ export class TaskDefinitionNotFoundError extends BaseError<{ id: string }> { } } - export class TaskNotFoundError extends BaseError { override readonly code = "BackgroundTasks/Task/NotFoundError" as const; diff --git a/packages/testing/tsconfig.build.json b/packages/testing/tsconfig.build.json index 3bf1618ebc5..ef1768d97bf 100644 --- a/packages/testing/tsconfig.build.json +++ b/packages/testing/tsconfig.build.json @@ -204,9 +204,6 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/packages/testing/tsconfig.json b/packages/testing/tsconfig.json index 7e14f593222..752e26b5790 100644 --- a/packages/testing/tsconfig.json +++ b/packages/testing/tsconfig.json @@ -204,9 +204,6 @@ "@webiny/api-record-locking": ["../api-record-locking/src"], "@webiny/api-website-builder/*": ["../api-website-builder/src/*"], "@webiny/api-website-builder": ["../api-website-builder/src"], - "@webiny/api-websockets/features/WebsocketService": [ - "../api-websockets/src/src/features/WebsocketService/index.js" - ], "@webiny/api-websockets/*": ["../api-websockets/src/*"], "@webiny/api-websockets": ["../api-websockets/src"], "@webiny/aws-sdk/*": ["../aws-sdk/src/*"], diff --git a/yarn.lock b/yarn.lock index ab96093594d..ef475dbd2a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14401,12 +14401,10 @@ __metadata: version: 0.0.0-use.local resolution: "@webiny/api-file-manager-ddb@workspace:packages/api-file-manager-ddb" dependencies: - "@webiny/api": "npm:0.0.0" "@webiny/api-file-manager": "npm:0.0.0" "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" - "@webiny/error": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" jest-dynalite: "npm:^3.6.1" jsonpack: "npm:^1.1.5" @@ -14425,7 +14423,7 @@ __metadata: "@webiny/api-websockets": "npm:0.0.0" "@webiny/aws-sdk": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/error": "npm:0.0.0" + "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" @@ -14462,12 +14460,9 @@ __metadata: "@webiny/handler-graphql": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" - "@webiny/pubsub": "npm:0.0.0" - "@webiny/tasks": "npm:0.0.0" "@webiny/utils": "npm:0.0.0" "@webiny/wcp": "npm:0.0.0" cache-control-parser: "npm:^2.0.6" - lodash: "npm:^4.17.21" object-hash: "npm:^3.0.0" rimraf: "npm:^6.0.1" typescript: "npm:5.9.3" @@ -14762,7 +14757,6 @@ __metadata: pluralize: "npm:^8.0.0" prettier: "npm:^3.6.2" rimraf: "npm:^6.0.1" - semver: "npm:^7.7.2" slugify: "npm:^1.6.6" typescript: "npm:5.9.3" vitest: "npm:^3.2.4" @@ -14837,15 +14831,14 @@ __metadata: "@webiny/api-headless-cms": "npm:0.0.0" "@webiny/api-websockets": "npm:0.0.0" "@webiny/build-tools": "npm:0.0.0" - "@webiny/error": "npm:0.0.0" "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-graphql": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" - "@webiny/pubsub": "npm:0.0.0" "@webiny/utils": "npm:0.0.0" + "@webiny/wcp": "npm:0.0.0" graphql: "npm:^16.12.0" rimraf: "npm:^6.0.1" type-fest: "npm:^5.2.0" @@ -15004,6 +14997,7 @@ __metadata: "@webiny/build-tools": "npm:0.0.0" "@webiny/db-dynamodb": "npm:0.0.0" "@webiny/error": "npm:0.0.0" + "@webiny/feature": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-db": "npm:0.0.0" @@ -16591,6 +16585,7 @@ __metadata: "@webiny/api-log": "npm:0.0.0" "@webiny/api-mailer": "npm:0.0.0" "@webiny/api-record-locking": "npm:0.0.0" + "@webiny/api-scheduler": "npm:0.0.0" "@webiny/api-security-cognito": "npm:0.0.0" "@webiny/api-website-builder": "npm:0.0.0" "@webiny/api-websockets": "npm:0.0.0" From 94a2863da18fbea23615469a54f0978c72b33753 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 1 Dec 2025 20:40:41 +0100 Subject: [PATCH 65/71] fix: eslint issues --- .../features/security/utils/AppPermissions.ts | 1 - .../file/DeleteFile/DeleteFileUseCase.ts | 6 +---- .../features/file/DeleteFile/abstractions.ts | 4 ---- packages/api-file-manager/src/index.ts | 2 +- .../tasks/MockDataCreator/MockDataCreator.ts | 1 - .../CancelScheduledActionOnDeleteHandler.ts | 2 +- .../CancelScheduledActionOnPublishHandler.ts | 2 +- ...CancelScheduledActionOnUnpublishHandler.ts | 2 +- .../src/graphql/index.ts | 2 +- .../security/contentEntries/write.test.ts | 1 - .../createUpdateEntryData.ts | 1 - .../src/domain/contentModel/schemas.ts | 22 ------------------- .../abstractions.ts | 1 - .../CreateModelFrom/abstractions.ts | 1 - .../CreateGroup/CreateGroupRepository.ts | 1 - .../src/features/RunAction/abstractions.ts | 2 +- .../src/admin/plugins/fields/ref.tsx | 6 ++--- 17 files changed, 9 insertions(+), 48 deletions(-) diff --git a/packages/api-core/src/features/security/utils/AppPermissions.ts b/packages/api-core/src/features/security/utils/AppPermissions.ts index 87d10a0170c..822db7dc91c 100644 --- a/packages/api-core/src/features/security/utils/AppPermissions.ts +++ b/packages/api-core/src/features/security/utils/AppPermissions.ts @@ -1,5 +1,4 @@ import type { SecurityPermission, SecurityIdentity, CreatedBy } from "~/types/security.js"; -import { NotAuthorizedError } from "~/features/security/shared/index.js"; const FULL_ACCESS_PERMISSION_NAME = "*"; diff --git a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts index d9132e23e13..fd21a3d1d49 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileUseCase.ts @@ -1,9 +1,5 @@ import { Result } from "@webiny/feature/api"; -import { - DeleteFileUseCase as UseCaseAbstraction, - DeleteFileInput, - DeleteFileRepository -} from "./abstractions.js"; +import { DeleteFileUseCase as UseCaseAbstraction, DeleteFileRepository } from "./abstractions.js"; import { GetFileUseCase } from "../GetFile/abstractions.js"; import { EventPublisher } from "@webiny/api-core/features/EventPublisher"; import { FileNotAuthorizedError } from "~/domain/file/errors.js"; diff --git a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts index c7d27c13f55..2339eae7aa0 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/abstractions.ts @@ -7,10 +7,6 @@ import { } from "~/domain/file/errors.js"; import { File } from "~/domain/file/types.js"; -export interface DeleteFileInput { - id: string; -} - /** * DeleteFile repository interface */ diff --git a/packages/api-file-manager/src/index.ts b/packages/api-file-manager/src/index.ts index e665c5d9987..94d272ee906 100644 --- a/packages/api-file-manager/src/index.ts +++ b/packages/api-file-manager/src/index.ts @@ -22,7 +22,7 @@ interface FileManagerContextParams { fileAliasStorageOperations: FileAliasStorageOperations; } -export const createFileManagerContext = (params: FileManagerContextParams) => { +export const createFileManagerContext = (_: FileManagerContextParams) => { const plugin = new ContextPlugin(async context => { const tenantContext = context.container.resolve(TenantContext); const getModel = context.container.resolve(GetModelUseCase); diff --git a/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts b/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts index 6f7bdb866bc..7237c8c4f51 100644 --- a/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts +++ b/packages/api-headless-cms-es-tasks/src/tasks/MockDataCreator/MockDataCreator.ts @@ -1,6 +1,5 @@ import type { ITaskResponseResult, ITaskRunParams } from "@webiny/tasks"; import type { IMockDataCreatorInput, IMockDataCreatorOutput } from "./types.js"; -import type { CmsModelManager } from "@webiny/api-headless-cms/types/index.js"; import { mockData } from "./mockData.js"; import { createWaitUntilHealthy } from "@webiny/api-elasticsearch/utils/waitUntilHealthy/index.js"; import type { Context } from "~/types.js"; diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts index 5159629067c..62eaf2b3eab 100644 --- a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnDeleteHandler.ts @@ -24,7 +24,7 @@ class CancelScheduledActionOnDeleteHandlerImpl implements EntryAfterDeleteHandle modelId: model.modelId, targetId: entry.id }); - } catch (error) { + } catch { // Silently ignore errors - this is non-critical cleanup // The entry was deleted successfully, cancelling scheduled actions is best-effort } diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts index 654984a1226..179b497185f 100644 --- a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnPublishHandler.ts @@ -24,7 +24,7 @@ class CancelScheduledActionOnPublishHandlerImpl implements EntryAfterPublishHand modelId: model.modelId, targetId: entry.id }); - } catch (error) { + } catch { // Silently ignore errors - this is non-critical cleanup // The entry was published successfully, cancelling scheduled actions is best-effort } diff --git a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts index 959b40a9d3b..746a3228018 100644 --- a/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts +++ b/packages/api-headless-cms-scheduler/src/features/CancelScheduledActionOnEntryChange/CancelScheduledActionOnUnpublishHandler.ts @@ -24,7 +24,7 @@ class CancelScheduledActionOnUnpublishHandlerImpl implements EntryAfterUnpublish modelId: model.modelId, targetId: entry.id }); - } catch (error) { + } catch { // Silently ignore errors - this is non-critical cleanup // The entry was unpublished successfully, cancelling scheduled actions is best-effort } diff --git a/packages/api-headless-cms-scheduler/src/graphql/index.ts b/packages/api-headless-cms-scheduler/src/graphql/index.ts index 3ff43360b31..bd18c0d14c5 100644 --- a/packages/api-headless-cms-scheduler/src/graphql/index.ts +++ b/packages/api-headless-cms-scheduler/src/graphql/index.ts @@ -179,7 +179,7 @@ export const createSchedulerGraphQL = () => { const listActions = context.container.resolve(ListScheduledActionsUseCase); - const { type, targetId, ...where } = validated.data.where ?? {}; + const { type, ...where } = validated.data.where ?? {}; if (type) { // @ts-expect-error diff --git a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts index 45fb9f57e20..87ded230442 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/security/contentEntries/write.test.ts @@ -62,7 +62,6 @@ describe("Write Permissions Checks", () => { entries: { rwd: "r" } }); - const p = permissions.getPermissions(); const { manage: manageApiB } = useTestModelHandler({ identity: identityB, permissions: permissions.getPermissions() diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts index b4e81783818..c397143fd0a 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createUpdateEntryData.ts @@ -16,7 +16,6 @@ import { removeNullValues, removeUndefinedValues } from "@webiny/utils"; import { getState } from "./state.js"; import type { SecurityIdentity } from "@webiny/api-core/types/security.js"; import type { Tenant } from "@webiny/api-core/types/tenancy.js"; -import type { I18NLocale } from "@webiny/api-core/types/i18n.js"; interface CreateEntryRevisionFromDataParams { metaInput?: Record; diff --git a/packages/api-headless-cms/src/domain/contentModel/schemas.ts b/packages/api-headless-cms/src/domain/contentModel/schemas.ts index b2203dd8cb6..65f4448debe 100644 --- a/packages/api-headless-cms/src/domain/contentModel/schemas.ts +++ b/packages/api-headless-cms/src/domain/contentModel/schemas.ts @@ -182,28 +182,6 @@ const refinementPluralValidationMessage = (value?: string) => { }; }; -const refinementModelIdValidation = (value?: string) => { - if (!value) { - return true; - } else if (value.match(/^[a-zA-Z]/) === null) { - return false; - } - const camelCasedValue = camelCase(value).toLowerCase(); - return camelCasedValue === value.toLowerCase(); -}; -const refinementModelIdValidationMessage = (value?: string) => { - if (!value) { - return {}; - } else if (value.match(/^[a-zA-Z]/) === null) { - return { - message: `The modelId "${value}" is not valid. It must start with a A-Z or a-z.` - }; - } - return { - message: `The modelId "${value}" is not valid.` - }; -}; - const modelIdTransformation = (value?: string) => { if (!value) { return value; diff --git a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts index 8f9082beb96..128249175b6 100644 --- a/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentEntry/GetPublishedRevisionByEntryId/abstractions.ts @@ -2,7 +2,6 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; import type { CmsEntry } from "~/types/index.js"; import type { CmsModel } from "~/types/index.js"; -import type { CmsEntryStorageOperationsGetPublishedRevisionParams } from "~/types/index.js"; import { EntryNotFoundError, type EntryPersistenceError } from "~/domain/contentEntry/errors.js"; /** diff --git a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts index 2225980379e..13d4fd92dbf 100644 --- a/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts +++ b/packages/api-headless-cms/src/features/contentModel/CreateModelFrom/abstractions.ts @@ -3,7 +3,6 @@ import { Result } from "@webiny/feature/api"; import type { CmsModel } from "~/types/index.js"; import type { CmsModelCreateFromInput } from "~/types/index.js"; import { - type ModelSlugTakenError, ModelNotAuthorizedError, type ModelNotFoundError, type ModelValidationError, diff --git a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts index 182139b13de..9f6332108cf 100644 --- a/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts +++ b/packages/api-headless-cms/src/features/contentModelGroup/CreateGroup/CreateGroupRepository.ts @@ -7,7 +7,6 @@ import { GroupSlugTakenError } from "~/domain/contentModelGroup/errors.js"; import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js"; import { StorageOperations } from "~/features/shared/abstractions.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; -import { CmsContext } from "~/features/shared/abstractions.js"; import { toSlug } from "~/utils/toSlug.js"; import { generateAlphaNumericId } from "@webiny/utils"; import type { CmsGroup } from "~/types/index.js"; diff --git a/packages/api-scheduler/src/features/RunAction/abstractions.ts b/packages/api-scheduler/src/features/RunAction/abstractions.ts index 678630209cd..042022ae280 100644 --- a/packages/api-scheduler/src/features/RunAction/abstractions.ts +++ b/packages/api-scheduler/src/features/RunAction/abstractions.ts @@ -1,6 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import { Result } from "@webiny/feature/api"; -import type { IScheduledAction, ISchedulerInput } from "~/shared/abstractions.js"; +import type { IScheduledAction } from "~/shared/abstractions.js"; import { ScheduledActionPersistenceError, InvalidScheduleDateError, diff --git a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx index f624ca4ad07..74fdb8bab27 100644 --- a/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fields/ref.tsx @@ -7,8 +7,8 @@ import type { CmsModel, CmsModelFieldTypePlugin } from "~/types.js"; import { ReactComponent as RefIcon } from "@webiny/icons/link.svg"; import { i18n } from "@webiny/app/i18n/index.js"; import type { BindComponentRenderProp } from "@webiny/form"; -import { Bind, useForm } from "@webiny/form"; -import { useModel, useQuery } from "~/admin/hooks/index.js"; +import { Bind } from "@webiny/form"; +import { useQuery } from "~/admin/hooks/index.js"; import { renderInfo } from "./ref/renderInfo.js"; import { CMS_MODEL_SINGLETON_TAG } from "@webiny/app-headless-cms-common"; import { Grid, Label, MultiAutoComplete } from "@webiny/admin-ui"; @@ -16,8 +16,6 @@ import { Grid, Label, MultiAutoComplete } from "@webiny/admin-ui"; const t = i18n.ns("app-headless-cms/admin/fields"); const RefFieldSettings = () => { - const { data: formData } = useForm(); - const { data, loading, error } = useQuery(LIST_CONTENT_MODELS); const { showSnackbar } = useSnackbar(); From 165bc66d622de10180b74cb24104e2173dca204e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Mon, 1 Dec 2025 20:43:22 +0100 Subject: [PATCH 66/71] fix: sync dependencies --- packages/cli/files/references.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/files/references.json b/packages/cli/files/references.json index 79e912783a6..b23b740f7ac 100644 --- a/packages/cli/files/references.json +++ b/packages/cli/files/references.json @@ -1 +1 @@ -{"dependencies":[{"name":"@apollo/react-common","version":"3.1.4","files":["/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"@apollo/react-components","version":"3.1.5","files":["/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-mailer/package.json"]},{"name":"@apollo/react-hooks","version":"3.1.5","files":["/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-record-locking/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"@auth0/auth0-react","version":"2.5.0","files":["/packages/app-admin-auth0/package.json"]},{"name":"@aws-amplify/auth","version":"5.6.15","files":["/packages/app-admin-cognito/package.json","/packages/app-cognito-authenticator/package.json"]},{"name":"@aws-sdk/client-apigatewaymanagementapi","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudfront","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudwatch-events","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudwatch-logs","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cognito-identity-provider","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-dynamodb-streams","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-eventbridge","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-iam","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-iot","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-lambda","version":"3.942.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-s3","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-scheduler","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sfn","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sqs","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sts","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/credential-providers","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/lib-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/lib-storage","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/s3-presigned-post","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/s3-request-presigner","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/util-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@babel/code-frame","version":"7.27.1","files":["/packages/api-headless-cms/package.json"]},{"name":"@babel/core","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-env","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-react","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-typescript","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/runtime","version":"7.28.4","files":["/packages/build-tools/package.json"]},{"name":"@elastic/elasticsearch","version":"7.12.0","files":["/packages/api-elasticsearch/package.json","/packages/data-migration/package.json","/packages/migrations/package.json"]},{"name":"@emotion/css","version":"11.10.6","files":["/packages/app-headless-cms/package.json"]},{"name":"@emotion/react","version":"11.10.8","files":["/packages/app-admin/package.json","/packages/app-audit-logs/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-theme/package.json","/packages/theme/package.json"]},{"name":"@emotion/styled","version":"11.10.6","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"@fastify/aws-lambda","version":"4.1.0","files":["/packages/handler-aws/package.json"]},{"name":"@fastify/compress","version":"7.0.3","files":["/packages/handler/package.json"]},{"name":"@fastify/cookie","version":"9.4.0","files":["/packages/handler/package.json"]},{"name":"@fortawesome/fontawesome-common-types","version":"0.3.0","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/fontawesome-svg-core","version":"1.3.0","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-workflows/package.json"]},{"name":"@fortawesome/free-brands-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/free-regular-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/free-solid-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/react-fontawesome","version":"0.1.19","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json"]},{"name":"@graphql-tools/merge","version":"9.1.6","files":["/packages/api-headless-cms/package.json","/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/resolvers-composition","version":"7.0.25","files":["/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/schema","version":"10.0.30","files":["/packages/api-headless-cms/package.json","/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/utils","version":"10.11.0","files":["/packages/handler-graphql/package.json"]},{"name":"@iconify/json","version":"2.2.386","files":["/packages/app-admin/package.json"]},{"name":"@lexical/code","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/hashtag","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/headless","version":"0.35.0","files":["/packages/lexical-converter/package.json"]},{"name":"@lexical/history","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/html","version":"0.35.0","files":["/packages/lexical-converter/package.json"]},{"name":"@lexical/list","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/mark","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/overflow","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/react","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/rich-text","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/selection","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/text","version":"0.35.0","files":["/packages/lexical-editor/package.json"]},{"name":"@lexical/utils","version":"0.35.0","files":["/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@material-design-icons/svg","version":"0.14.15","files":["/packages/app-headless-cms-scheduler/package.json"]},{"name":"@minoru/react-dnd-treeview","version":"3.5.3","files":["/packages/admin-ui/package.json"]},{"name":"@monaco-editor/react","version":"4.7.0","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@noble/hashes","version":"2.0.0","files":["/packages/utils/package.json"]},{"name":"@okta/okta-auth-js","version":"5.11.0","files":["/packages/app-admin-okta/package.json"]},{"name":"@okta/okta-react","version":"6.10.0","files":["/packages/app-admin-okta/package.json"]},{"name":"@okta/okta-signin-widget","version":"5.16.1","files":["/packages/app-admin-okta/package.json"]},{"name":"@pulumi/aws","version":"7.12.0","files":["/packages/project-aws/package.json","/packages/pulumi-sdk/package.json"]},{"name":"@pulumi/pulumi","version":"3.209.0","files":["/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json"]},{"name":"@pulumi/random","version":"4.18.4","files":["/packages/project-aws/package.json"]},{"name":"@rsbuild/core","version":"1.6.0","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-react","version":"1.4.1","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-sass","version":"1.4.0","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-svgr","version":"1.2.2","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-type-check","version":"1.3.0","files":["/packages/build-tools/package.json"]},{"name":"@rspack/core","version":"1.5.5","files":["/packages/build-tools/package.json"]},{"name":"@smithy/node-http-handler","version":"2.5.0","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@svgr/webpack","version":"6.5.1","files":["/packages/app-admin/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/build-tools/package.json","/packages/ui/package.json"]},{"name":"@swc/plugin-emotion","version":"11.1.0","files":["/packages/build-tools/package.json"]},{"name":"@tailwindcss/postcss","version":"4.1.16","files":["/packages/build-tools/package.json"]},{"name":"@tanstack/react-table","version":"8.21.3","files":["/packages/admin-ui/package.json"]},{"name":"@types/aws-lambda","version":"8.10.152","files":["/packages/aws-helpers/package.json","/packages/aws-sdk/package.json"]},{"name":"@types/hoist-non-react-statics","version":"3.3.7","files":["/package.json"]},{"name":"@types/mime","version":"2.0.3","files":["/packages/app-admin/package.json"]},{"name":"@types/prismjs","version":"1.26.5","files":["/packages/lexical-nodes/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-ui/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"@types/webpack-env","version":"1.18.8","files":["/packages/build-tools/package.json"]},{"name":"accounting","version":"0.4.1","files":["/packages/i18n/package.json"]},{"name":"apollo-cache","version":"1.3.5","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"apollo-cache-inmemory","version":"1.6.6","files":["/packages/app/package.json"]},{"name":"apollo-client","version":"2.6.10","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"apollo-link","version":"1.2.14","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"apollo-link-batch-http","version":"1.2.14","files":["/packages/app-serverless-cms/package.json"]},{"name":"apollo-link-context","version":"1.0.20","files":["/packages/app/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-graphql-playground/package.json"]},{"name":"apollo-link-error","version":"1.1.13","files":["/packages/app/package.json"]},{"name":"apollo-link-http-common","version":"0.2.16","files":["/packages/app/package.json"]},{"name":"apollo-utilities","version":"1.3.4","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"archiver","version":"7.0.1","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"boolean","version":"3.2.0","files":["/packages/app/package.json","/packages/handler-graphql/package.json"]},{"name":"bson-objectid","version":"2.0.4","files":["/packages/utils/package.json"]},{"name":"bytes","version":"3.1.2","files":["/packages/admin-ui/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-sync-system/package.json","/packages/app/package.json","/packages/app-file-manager/package.json"]},{"name":"cache-control-parser","version":"2.0.6","files":["/packages/api-file-manager/package.json"]},{"name":"case","version":"1.6.3","files":["/packages/app-admin/package.json","/packages/project/package.json"]},{"name":"center-align","version":"1.0.1","files":["/packages/data-migration/package.json"]},{"name":"chalk","version":"4.1.2","files":["/packages/aws-layers/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/data-migration/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/system-requirements/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json","/scripts/cli/package.json"]},{"name":"cheerio","version":"1.1.2","files":["/packages/aws-helpers/package.json","/packages/lexical-converter/package.json"]},{"name":"chokidar","version":"4.0.3","files":["/packages/build-tools/package.json","/packages/project/package.json"]},{"name":"ci-info","version":"4.3.0","files":["/packages/global-config/package.json","/packages/project/package.json","/packages/telemetry/package.json"]},{"name":"class-variance-authority","version":"0.7.1","files":["/packages/admin-ui/package.json"]},{"name":"classnames","version":"2.5.1","files":["/packages/app-admin/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/ui/package.json"]},{"name":"cli-progress","version":"3.12.0","files":["/scripts/cjsToEsm/package.json"]},{"name":"cli-table3","version":"0.6.5","files":["/packages/system-requirements/package.json"]},{"name":"clsx","version":"2.1.1","files":["/packages/admin-ui/package.json"]},{"name":"cmdk","version":"1.1.1","files":["/packages/admin-ui/package.json"]},{"name":"core-js","version":"3.45.1","files":["/packages/project-aws/package.json"]},{"name":"cropperjs","version":"1.6.2","files":["/packages/app-file-manager/package.json"]},{"name":"cross-fetch","version":"3.2.0","files":["/packages/project-aws/package.json"]},{"name":"crypto-hash","version":"3.1.0","files":["/packages/app-record-locking/package.json"]},{"name":"crypto-js","version":"4.2.0","files":["/packages/api-mailer/package.json"]},{"name":"css-loader","version":"7.1.2","files":["/packages/build-tools/package.json"]},{"name":"csstype","version":"3.1.3","files":["/packages/website-builder-sdk/package.json"]},{"name":"dataloader","version":"2.2.3","files":["/packages/api-core/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json"]},{"name":"dataurl-to-blob","version":"0.0.1","files":["/packages/app-file-manager/package.json"]},{"name":"date-fns","version":"2.30.0","files":["/packages/app-audit-logs/package.json","/packages/db-dynamodb/package.json"]},{"name":"dayjs","version":"1.11.18","files":["/packages/app-file-manager/package.json"]},{"name":"debounce","version":"1.2.1","files":["/packages/project/package.json"]},{"name":"decompress","version":"4.2.1","files":["/packages/pulumi-sdk/package.json"]},{"name":"deep-equal","version":"2.2.3","files":["/packages/api-core/package.json","/packages/api-security-cognito/package.json","/packages/app-website-builder/package.json","/packages/tasks/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"deepmerge","version":"4.3.1","files":["/packages/website-builder-sdk/package.json"]},{"name":"dnd-core","version":"16.0.1","files":["/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json"]},{"name":"dot-object","version":"2.1.5","files":["/packages/api-headless-cms-ddb/package.json"]},{"name":"dot-prop","version":"6.0.1","files":["/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb/package.json","/packages/db-dynamodb/package.json"]},{"name":"dot-prop-immutable","version":"2.1.1","files":["/packages/app-aco/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json"]},{"name":"dotenv","version":"8.6.0","files":["/packages/project/package.json"]},{"name":"dynamodb-toolbox","version":"0.9.5","files":["/packages/db-dynamodb/package.json"]},{"name":"elastic-ts","version":"0.12.0","files":["/packages/api-elasticsearch/package.json"]},{"name":"emotion","version":"10.0.27","files":["/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/lexical-theme/package.json"]},{"name":"eslint","version":"9.39.1","files":["/packages/build-tools/package.json"]},{"name":"execa","version":"5.1.1","files":["/packages/cli/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project/package.json","/packages/pulumi-sdk/package.json","/packages/system-requirements/package.json","/scripts/buildPackages/package.json"]},{"name":"exit-hook","version":"4.0.0","files":["/packages/project/package.json"]},{"name":"fast-glob","version":"3.3.3","files":["/packages/build-tools/package.json","/packages/project/package.json","/scripts/cjsToEsm/package.json"]},{"name":"fast-json-patch","version":"3.1.1","files":["/packages/website-builder-sdk/package.json"]},{"name":"fast-json-stable-stringify","version":"2.1.0","files":["/packages/website-builder-sdk/package.json"]},{"name":"fastify","version":"4.29.1","files":["/packages/handler/package.json","/packages/handler-aws/package.json"]},{"name":"fecha","version":"2.3.3","files":["/packages/i18n/package.json"]},{"name":"find-up","version":"5.0.0","files":["/packages/build-tools/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/scripts/prepublishOnly/package.json","/scripts/cli/package.json","/scripts/cjsToEsm/package.json"]},{"name":"folder-hash","version":"4.1.1","files":["/scripts/buildPackages/package.json"]},{"name":"fs-extra","version":"11.3.2","files":["/packages/build-tools/package.json","/packages/create-webiny-project/package.json","/packages/pulumi-sdk/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"fuse.js","version":"7.1.0","files":["/packages/db-dynamodb/package.json"]},{"name":"get-yarn-workspaces","version":"1.0.2","files":["/packages/build-tools/package.json","/packages/cli-core/package.json","/packages/project-utils/package.json","/scripts/prepublishOnly/package.json"]},{"name":"glob","version":"7.2.3","files":["/packages/cli-core/package.json","/packages/i18n/package.json"]},{"name":"graphlib","version":"2.1.8","files":["/packages/app-admin/package.json"]},{"name":"graphql","version":"16.12.0","files":["/packages/api-headless-cms/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-mailer/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/handler-graphql/package.json"]},{"name":"graphql-request","version":"7.3.5","files":["/packages/project/package.json"]},{"name":"graphql-scalars","version":"1.25.0","files":["/packages/handler-graphql/package.json"]},{"name":"graphql-tag","version":"2.12.6","files":["/packages/api-headless-cms/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/handler-graphql/package.json"]},{"name":"history","version":"5.3.0","files":["/packages/app/package.json","/packages/app-admin/package.json"]},{"name":"humanize-duration","version":"3.33.1","files":["/packages/cli-core/package.json","/packages/project/package.json"]},{"name":"inquirer","version":"12.9.6","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json"]},{"name":"invariant","version":"2.2.4","files":["/packages/app/package.json","/packages/project-aws/package.json"]},{"name":"inversify","version":"6.2.2","files":["/packages/ioc/package.json"]},{"name":"is-hotkey","version":"0.2.0","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"isnumeric","version":"0.3.3","files":["/packages/validation/package.json"]},{"name":"jose","version":"5.10.0","files":["/packages/api-core/package.json"]},{"name":"js-yaml","version":"4.1.1","files":["/packages/create-webiny-project/package.json"]},{"name":"jsdom","version":"25.0.1","files":["/packages/api-headless-cms/package.json"]},{"name":"jsesc","version":"3.1.0","files":["/packages/telemetry/package.json"]},{"name":"jsonpack","version":"1.1.5","files":["/packages/utils/package.json"]},{"name":"jsonwebtoken","version":"9.0.2","files":["/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-security-auth0/package.json","/packages/api-security-okta/package.json"]},{"name":"lexical","version":"0.35.0","files":["/packages/lexical-converter/package.json","/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json","/packages/lexical-theme/package.json","/packages/website-builder-sdk/package.json"]},{"name":"listr","version":"0.14.3","files":["/packages/create-webiny-project/package.json"]},{"name":"listr2","version":"5.0.8","files":["/scripts/buildPackages/package.json"]},{"name":"load-json-file","version":"6.2.0","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/global-config/package.json","/packages/telemetry/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"load-script","version":"1.0.0","files":["/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json"]},{"name":"lodash","version":"4.17.21","files":["/packages/admin-ui/package.json","/packages/api-aco/package.json","/packages/api-core/package.json","/packages/api-file-manager/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-mailer/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security-access-management/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/build-tools/package.json","/packages/db-dynamodb/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/lexical-editor/package.json","/packages/migrations/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/project-utils/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/tasks/package.json","/packages/ui/package.json","/packages/validation/package.json","/packages/website-builder-sdk/package.json"]},{"name":"matcher","version":"5.0.0","files":["/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"md5","version":"2.3.0","files":["/packages/api-core/package.json"]},{"name":"mime","version":"3.0.0","files":["/packages/api-file-manager-s3/package.json","/packages/app-file-manager/package.json","/packages/project-aws/package.json"]},{"name":"minimatch","version":"5.1.6","files":["/packages/admin-ui/package.json","/packages/api-core/package.json","/packages/app/package.json","/packages/app-file-manager/package.json","/packages/app-security/package.json","/packages/data-migration/package.json","/packages/project/package.json"]},{"name":"mobx","version":"6.15.0","files":["/packages/admin-ui/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-trash-bin/package.json","/packages/app-utils/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"mobx-react-lite","version":"3.4.3","files":["/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/website-builder-react/package.json"]},{"name":"monaco-editor","version":"0.53.0","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json"]},{"name":"mqtt","version":"5.14.1","files":["/packages/project/package.json"]},{"name":"nanoid","version":"3.3.11","files":["/packages/app/package.json","/packages/react-properties/package.json","/packages/utils/package.json","/packages/website-builder-sdk/package.json"]},{"name":"nanoid-dictionary","version":"4.3.0","files":["/packages/utils/package.json","/packages/website-builder-sdk/package.json"]},{"name":"neverthrow","version":"8.2.0","files":["/packages/project/package.json"]},{"name":"nodemailer","version":"7.0.10","files":["/packages/api-mailer/package.json"]},{"name":"object-hash","version":"3.0.0","files":["/packages/api-file-manager/package.json","/packages/api-file-manager-s3/package.json"]},{"name":"object-merge-advanced","version":"12.1.0","files":["/packages/tasks/package.json"]},{"name":"object-sizeof","version":"2.6.5","files":["/packages/tasks/package.json"]},{"name":"open","version":"10.2.0","files":["/packages/cli-core/package.json"]},{"name":"ora","version":"4.1.1","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project-aws/package.json"]},{"name":"os","version":"0.1.2","files":["/packages/create-webiny-project/package.json"]},{"name":"p-limit","version":"7.1.1","files":["/scripts/cjsToEsm/package.json"]},{"name":"p-map","version":"7.0.3","files":["/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json"]},{"name":"p-reduce","version":"3.0.0","files":["/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json"]},{"name":"p-retry","version":"7.0.0","files":["/packages/api-dynamodb-to-elasticsearch/package.json","/packages/app-file-manager-s3/package.json","/packages/project/package.json","/packages/utils/package.json"]},{"name":"pako","version":"2.1.0","files":["/packages/app-aco/package.json"]},{"name":"pino","version":"9.13.1","files":["/packages/website-builder-sdk/package.json","/scripts/cli/package.json"]},{"name":"pino-pretty","version":"9.4.1","files":["/packages/data-migration/package.json","/packages/website-builder-sdk/package.json"]},{"name":"pluralize","version":"8.0.0","files":["/packages/api-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"postcss","version":"8.5.6","files":["/packages/website-builder-nextjs/package.json"]},{"name":"postcss-import","version":"16.1.1","files":["/packages/website-builder-nextjs/package.json"]},{"name":"postcss-loader","version":"8.2.0","files":["/packages/build-tools/package.json"]},{"name":"process","version":"0.11.10","files":["/packages/build-tools/package.json"]},{"name":"prop-types","version":"15.8.1","files":["/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-mailer/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"radix-ui","version":"1.4.3","files":["/packages/admin-ui/package.json"]},{"name":"raw-loader","version":"4.0.2","files":["/packages/build-tools/package.json"]},{"name":"raw.macro","version":"0.4.2","files":["/packages/app-headless-cms/package.json"]},{"name":"react","version":"18.2.0","files":["/packages/admin-ui/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/website-builder-react/package.json"]},{"name":"react-butterfiles","version":"1.3.3","files":["/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-color","version":"2.19.3","files":["/packages/admin-ui/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"react-custom-scrollbars","version":"4.2.1","files":["/packages/admin-ui/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-dnd","version":"16.0.1","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"react-dnd-html5-backend","version":"16.0.1","files":["/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/build-tools/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/react-composition/package.json","/packages/website-builder-react/package.json"]},{"name":"react-draggable","version":"4.5.0","files":["/packages/app-admin/package.json"]},{"name":"react-helmet","version":"6.1.0","files":["/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-security-access-management/package.json","/packages/app-workflows/package.json"]},{"name":"react-hotkeyz","version":"1.0.4","files":["/packages/app-aco/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-lazy-load","version":"3.1.14","files":["/packages/app-file-manager/package.json"]},{"name":"react-refresh","version":"0.11.0","files":["/packages/build-tools/package.json"]},{"name":"react-resizable","version":"3.0.5","files":["/packages/app-admin/package.json"]},{"name":"react-resizable-panels","version":"2.1.9","files":["/packages/app-admin/package.json"]},{"name":"react-style-object-to-css","version":"1.1.2","files":["/packages/lexical-theme/package.json"]},{"name":"react-test-renderer","version":"18.3.1","files":["/packages/project/package.json"]},{"name":"react-transition-group","version":"4.4.5","files":["/packages/app-admin/package.json"]},{"name":"react-virtualized","version":"9.22.6","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json"]},{"name":"read-json-sync","version":"2.0.1","files":["/packages/build-tools/package.json","/packages/project/package.json","/packages/pulumi-sdk/package.json","/scripts/cjsToEsm/package.json"]},{"name":"reflect-metadata","version":"0.2.2","files":["/packages/ioc/package.json"]},{"name":"regenerator-runtime","version":"0.14.1","files":["/packages/project-aws/package.json"]},{"name":"replace-in-path","version":"1.1.0","files":["/packages/project/package.json"]},{"name":"reset-css","version":"5.0.2","files":["/packages/app-admin/package.json"]},{"name":"rimraf","version":"6.0.1","files":["/packages/build-tools/package.json","/packages/create-webiny-project/package.json"]},{"name":"sanitize-filename","version":"1.6.3","files":["/packages/api-file-manager-s3/package.json"]},{"name":"sass","version":"1.93.0","files":["/packages/build-tools/package.json"]},{"name":"sass-loader","version":"16.0.5","files":["/packages/build-tools/package.json"]},{"name":"semver","version":"7.7.2","files":["/packages/api-headless-cms/package.json","/packages/api-sync-system/package.json","/packages/cli-core/package.json","/packages/data-migration/package.json","/packages/pulumi-sdk/package.json","/packages/system-requirements/package.json"]},{"name":"serialize-error","version":"12.0.0","files":["/packages/project/package.json","/scripts/buildPackages/package.json"]},{"name":"sharp","version":"0.34.4","files":["/packages/api-file-manager-s3/package.json"]},{"name":"short-hash","version":"1.0.0","files":["/packages/i18n/package.json"]},{"name":"slugify","version":"1.6.6","files":["/packages/api-headless-cms/package.json","/packages/app-aco/package.json","/packages/app-website-builder/package.json"]},{"name":"sonner","version":"2.0.7","files":["/packages/admin-ui/package.json"]},{"name":"srcset","version":"4.0.0","files":["/packages/aws-helpers/package.json"]},{"name":"store","version":"2.0.12","files":["/packages/app-aco/package.json","/packages/app-admin/package.json"]},{"name":"strip-ansi","version":"6.0.1","files":["/packages/telemetry/package.json"]},{"name":"style-loader","version":"3.3.4","files":["/packages/build-tools/package.json"]},{"name":"tailwind-merge","version":"2.6.0","files":["/packages/admin-ui/package.json"]},{"name":"tailwindcss","version":"4.1.16","files":["/packages/admin-ui/package.json"]},{"name":"tar","version":"6.2.1","files":["/packages/pulumi-sdk/package.json"]},{"name":"timeago-react","version":"3.0.7","files":["/packages/admin-ui/package.json"]},{"name":"tinycolor2","version":"1.6.0","files":["/packages/app-admin/package.json"]},{"name":"ts-invariant","version":"0.10.3","files":["/packages/app/package.json"]},{"name":"ts-morph","version":"24.0.0","files":["/packages/app-admin/package.json","/packages/build-tools/package.json","/packages/project/package.json","/scripts/cjsToEsm/package.json"]},{"name":"tsx","version":"4.20.5","files":["/packages/build-tools/package.json","/packages/cli/package.json","/packages/project/package.json","/scripts/buildPackages/package.json","/scripts/cjsToEsm/package.json"]},{"name":"tw-animate-css","version":"1.4.0","files":["/packages/admin-ui/package.json"]},{"name":"type-fest","version":"5.2.0","files":["/packages/admin-ui/package.json","/packages/api-websockets/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/db/package.json","/scripts/cli/package.json"]},{"name":"typescript","version":"5.9.3","files":["/packages/build-tools/package.json"]},{"name":"unicode-emoji-json","version":"0.8.0","files":["/packages/app-admin/package.json"]},{"name":"uniqid","version":"5.4.0","files":["/packages/api-headless-cms-import-export/package.json","/packages/plugins/package.json"]},{"name":"universal-router","version":"9.2.1","files":["/packages/app/package.json"]},{"name":"unzipper","version":"0.12.3","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"url-loader","version":"4.1.1","files":["/packages/build-tools/package.json"]},{"name":"use-deep-compare-effect","version":"1.8.1","files":["/packages/app-headless-cms/package.json"]},{"name":"utf-8-validate","version":"6.0.5","files":["/packages/build-tools/package.json"]},{"name":"uuid","version":"13.0.0","files":["/packages/create-webiny-project/package.json","/packages/global-config/package.json"]},{"name":"validate-npm-package-name","version":"6.0.2","files":["/packages/create-webiny-project/package.json"]},{"name":"vitest","version":"3.2.4","files":["/packages/api-headless-cms-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"warning","version":"4.0.3","files":["/packages/app/package.json"]},{"name":"write-json-file","version":"4.3.0","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/global-config/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"wts-client","version":"2.0.0","files":["/packages/telemetry/package.json"]},{"name":"yargs","version":"17.7.2","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/i18n/package.json","/scripts/buildPackages/package.json","/scripts/cli/package.json"]},{"name":"yesno","version":"0.4.0","files":["/packages/create-webiny-project/package.json"]},{"name":"zod","version":"3.25.76","files":["/packages/api-audit-logs/package.json","/packages/api-core/package.json","/packages/api-file-manager/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-sync-system/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/cli-core/package.json","/packages/handler-graphql/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/tasks/package.json","/packages/utils/package.json"]}],"devDependencies":[{"name":"@4tw/cypress-drag-drop","version":"1.8.1","files":["/cypress-tests/package.json"]},{"name":"@babel/cli","version":"7.28.3","files":["/package.json"]},{"name":"@babel/code-frame","version":"7.27.1","files":["/package.json"]},{"name":"@babel/compat-data","version":"7.28.5","files":["/package.json"]},{"name":"@babel/core","version":"7.28.5","files":["/package.json"]},{"name":"@babel/helper-define-polyfill-provider","version":"0.6.5","files":["/package.json"]},{"name":"@babel/helper-environment-visitor","version":"7.24.7","files":["/package.json"]},{"name":"@babel/parser","version":"7.28.5","files":["/package.json"]},{"name":"@babel/plugin-proposal-class-properties","version":"7.18.6","files":["/package.json"]},{"name":"@babel/plugin-proposal-object-rest-spread","version":"7.20.7","files":["/package.json"]},{"name":"@babel/plugin-proposal-throw-expressions","version":"7.27.1","files":["/package.json"]},{"name":"@babel/plugin-syntax-object-rest-spread","version":"7.8.3","files":["/package.json"]},{"name":"@babel/plugin-transform-modules-commonjs","version":"7.27.1","files":["/package.json"]},{"name":"@babel/plugin-transform-runtime","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-env","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-react","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-typescript","version":"7.28.5","files":["/package.json"]},{"name":"@babel/register","version":"7.28.3","files":["/package.json","/packages/i18n/package.json"]},{"name":"@babel/runtime","version":"7.28.4","files":["/package.json"]},{"name":"@babel/template","version":"7.27.2","files":["/package.json"]},{"name":"@babel/traverse","version":"7.28.5","files":["/package.json"]},{"name":"@babel/types","version":"7.28.5","files":["/package.json"]},{"name":"@commitlint/cli","version":"11.0.0","files":["/package.json"]},{"name":"@commitlint/config-conventional","version":"11.0.0","files":["/package.json"]},{"name":"@elastic/elasticsearch","version":"7.12.0","files":["/packages/api-headless-cms-ddb-es/package.json","/packages/project-utils/package.json"]},{"name":"@emotion/babel-plugin","version":"11.13.5","files":["/packages/app-admin/package.json","/packages/app-admin-ui/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"@emotion/react","version":"11.10.8","files":["/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"@eslint/eslintrc","version":"3.3.1","files":["/package.json"]},{"name":"@eslint/js","version":"9.39.1","files":["/package.json"]},{"name":"@faker-js/faker","version":"9.9.0","files":["/packages/api-headless-cms-es-tasks/package.json","/packages/api-sync-system/package.json"]},{"name":"@fortawesome/free-solid-svg-icons","version":"6.7.2","files":["/packages/admin-ui/package.json"]},{"name":"@grpc/grpc-js","version":"1.14.0","files":["/package.json"]},{"name":"@lexical/code","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/hashtag","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/headless","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/history","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/html","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/list","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/mark","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/overflow","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/react","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/rich-text","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/selection","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/text","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/utils","version":"0.35.0","files":["/package.json"]},{"name":"@material-design-icons/svg","version":"0.14.15","files":["/packages/icons/package.json"]},{"name":"@octokit/rest","version":"20.1.2","files":["/package.json"]},{"name":"@storybook/addon-a11y","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/addon-docs","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/addon-webpack5-compiler-babel","version":"3.0.6","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/react-webpack5","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@svgr/webpack","version":"6.5.1","files":["/packages/admin-ui/package.json","/packages/app-file-manager/package.json"]},{"name":"@tailwindcss/postcss","version":"4.1.16","files":["/packages/admin-ui/package.json"]},{"name":"@testing-library/cypress","version":"10.1.0","files":["/cypress-tests/package.json"]},{"name":"@testing-library/react","version":"15.0.7","files":["/packages/form/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/ui/package.json"]},{"name":"@testing-library/user-event","version":"14.6.1","files":["/packages/form/package.json"]},{"name":"@types/accounting","version":"0.4.5","files":["/packages/i18n/package.json"]},{"name":"@types/adm-zip","version":"0.5.7","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/archiver","version":"6.0.3","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/babel__code-frame","version":"7.0.6","files":["/packages/api-headless-cms/package.json"]},{"name":"@types/bytes","version":"3.1.5","files":["/packages/app-admin/package.json"]},{"name":"@types/center-align","version":"1.0.2","files":["/packages/data-migration/package.json"]},{"name":"@types/cli-progress","version":"3.11.6","files":["/scripts/cjsToEsm/package.json"]},{"name":"@types/crypto-js","version":"4.2.2","files":["/packages/api-mailer/package.json"]},{"name":"@types/debounce","version":"1.2.4","files":["/packages/project/package.json"]},{"name":"@types/deep-equal","version":"1.0.4","files":["/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"@types/dot-object","version":"2.1.6","files":["/packages/api-headless-cms-ddb/package.json"]},{"name":"@types/folder-hash","version":"4.0.4","files":["/scripts/buildPackages/package.json"]},{"name":"@types/fs-extra","version":"11.0.4","files":["/package.json"]},{"name":"@types/glob","version":"7.2.0","files":["/packages/i18n/package.json"]},{"name":"@types/graphlib","version":"2.1.12","files":["/packages/app-admin/package.json"]},{"name":"@types/humanize-duration","version":"3.27.4","files":["/packages/project/package.json"]},{"name":"@types/inquirer","version":"8.2.12","files":["/package.json"]},{"name":"@types/invariant","version":"2.2.37","files":["/packages/form/package.json"]},{"name":"@types/is-hotkey","version":"0.1.10","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"@types/is-number","version":"7.0.5","files":["/packages/db-dynamodb/package.json"]},{"name":"@types/js-yaml","version":"4.0.9","files":["/packages/create-webiny-project/package.json"]},{"name":"@types/jsdom","version":"21.1.7","files":["/packages/lexical-converter/package.json","/packages/project/package.json"]},{"name":"@types/jsonpack","version":"1.1.6","files":["/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json"]},{"name":"@types/jsonwebtoken","version":"9.0.10","files":["/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json"]},{"name":"@types/jwk-to-pem","version":"2.0.3","files":["/packages/api-cognito-authenticator/package.json","/packages/api-security-auth0/package.json"]},{"name":"@types/listr","version":"0.14.9","files":["/packages/cli-core/package.json"]},{"name":"@types/lodash","version":"4.17.20","files":["/packages/api-sync-system/package.json","/packages/app/package.json","/packages/app-cognito-authenticator/package.json","/packages/cli/package.json","/packages/cli-core/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/validation/package.json"]},{"name":"@types/md5","version":"2.3.5","files":["/packages/api-core/package.json"]},{"name":"@types/ncp","version":"2.0.8","files":["/packages/project-aws/package.json"]},{"name":"@types/node","version":"22.18.6","files":["/package.json"]},{"name":"@types/nodemailer","version":"6.4.19","files":["/packages/api-mailer/package.json"]},{"name":"@types/object-hash","version":"3.0.6","files":["/packages/api-file-manager/package.json"]},{"name":"@types/pako","version":"2.0.4","files":["/packages/app-website-builder/package.json"]},{"name":"@types/platform","version":"1.3.6","files":["/packages/app-website-builder/package.json"]},{"name":"@types/pluralize","version":"0.0.33","files":["/packages/api-headless-cms/package.json"]},{"name":"@types/postcss-import","version":"14.0.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"@types/randomcolor","version":"0.5.9","files":["/packages/app-website-builder/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/package.json","/packages/admin-ui/package.json","/packages/app-aco/package.json","/packages/app-audit-logs/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-trash-bin/package.json","/packages/app-workflows/package.json","/packages/theme/package.json","/packages/website-builder-react/package.json"]},{"name":"@types/react-color","version":"2.17.12","files":["/packages/admin-ui/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"@types/react-custom-scrollbars","version":"4.0.13","files":["/packages/admin-ui/package.json"]},{"name":"@types/react-dom","version":"18.2.25","files":["/package.json"]},{"name":"@types/react-helmet","version":"6.1.11","files":["/packages/app-admin-ui/package.json","/packages/app-security-access-management/package.json"]},{"name":"@types/react-images","version":"0.5.3","files":["/packages/app-website-builder/package.json"]},{"name":"@types/react-resizable","version":"3.0.8","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/react-test-renderer","version":"18.3.1","files":["/packages/project/package.json"]},{"name":"@types/react-transition-group","version":"4.4.12","files":["/packages/app-admin/package.json"]},{"name":"@types/react-virtualized","version":"9.22.3","files":["/packages/admin-ui/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/read-json-sync","version":"2.0.3","files":["/packages/project/package.json"]},{"name":"@types/resize-observer-browser","version":"0.1.11","files":["/packages/app-website-builder/package.json"]},{"name":"@types/semver","version":"7.7.1","files":["/packages/data-migration/package.json"]},{"name":"@types/store","version":"2.0.5","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/tinycolor2","version":"1.4.6","files":["/packages/app-admin/package.json"]},{"name":"@types/uniqid","version":"5.3.4","files":["/packages/feature-flags/package.json","/packages/plugins/package.json"]},{"name":"@types/universal-router","version":"8.0.0","files":["/packages/app/package.json"]},{"name":"@types/unzipper","version":"0.10.11","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/validate-npm-package-name","version":"3.0.3","files":["/packages/create-webiny-project/package.json"]},{"name":"@types/warning","version":"3.0.3","files":["/packages/app/package.json"]},{"name":"@types/yargs","version":"17.0.33","files":["/scripts/buildPackages/package.json"]},{"name":"@typescript-eslint/eslint-plugin","version":"8.48.0","files":["/package.json"]},{"name":"@typescript-eslint/parser","version":"8.48.0","files":["/package.json"]},{"name":"@vitest/coverage-v8","version":"3.2.4","files":["/package.json"]},{"name":"@vitest/eslint-plugin","version":"1.4.2","files":["/package.json"]},{"name":"adio","version":"2.0.1","files":["/package.json"]},{"name":"adm-zip","version":"0.5.16","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"amazon-cognito-identity-js","version":"4.6.3","files":["/cypress-tests/package.json"]},{"name":"apollo-client","version":"2.6.10","files":["/packages/app-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"apollo-graphql","version":"0.9.7","files":["/packages/api-headless-cms/package.json"]},{"name":"apollo-link","version":"1.2.14","files":["/packages/app-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"aws-sdk-client-mock","version":"4.1.0","files":["/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-sync-system/package.json"]},{"name":"axios","version":"1.12.2","files":["/package.json"]},{"name":"babel-loader","version":"10.0.0","files":["/package.json","/packages/ui/package.json"]},{"name":"babel-plugin-dynamic-import-node","version":"2.3.3","files":["/package.json"]},{"name":"babel-plugin-macros","version":"3.1.0","files":["/package.json"]},{"name":"babel-plugin-module-resolver","version":"5.0.2","files":["/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json"]},{"name":"babel-plugin-named-asset-import","version":"1.0.0-next.fb6e6f70","files":["/packages/app-admin-ui/package.json"]},{"name":"chalk","version":"4.1.2","files":["/package.json","/packages/admin-ui/package.json"]},{"name":"cross-env","version":"5.2.1","files":["/package.json"]},{"name":"cross-spawn","version":"6.0.6","files":["/package.json"]},{"name":"css-loader","version":"7.1.2","files":["/packages/admin-ui/package.json"]},{"name":"cypress","version":"13.17.0","files":["/cypress-tests/package.json"]},{"name":"cypress-image-snapshot","version":"4.0.1","files":["/cypress-tests/package.json"]},{"name":"cypress-mailosaur","version":"2.17.0","files":["/cypress-tests/package.json"]},{"name":"cypress-wait-until","version":"1.7.2","files":["/cypress-tests/package.json"]},{"name":"deepmerge","version":"4.3.1","files":["/package.json"]},{"name":"del","version":"6.1.1","files":["/cypress-tests/package.json"]},{"name":"elastic-ts","version":"0.12.0","files":["/packages/migrations/package.json"]},{"name":"env-ci","version":"2.6.0","files":["/package.json"]},{"name":"eslint","version":"9.39.1","files":["/package.json"]},{"name":"eslint-config-standard","version":"17.1.0","files":["/package.json"]},{"name":"eslint-import-resolver-babel-module","version":"5.3.2","files":["/package.json"]},{"name":"eslint-plugin-import","version":"2.32.0","files":["/package.json"]},{"name":"eslint-plugin-lodash","version":"8.0.0","files":["/package.json"]},{"name":"eslint-plugin-node","version":"11.1.0","files":["/package.json"]},{"name":"eslint-plugin-promise","version":"7.2.1","files":["/package.json"]},{"name":"eslint-plugin-react","version":"7.37.5","files":["/package.json"]},{"name":"eslint-plugin-standard","version":"5.0.0","files":["/package.json"]},{"name":"eslint-plugin-storybook","version":"9.1.16","files":["/packages/admin-ui/package.json"]},{"name":"execa","version":"5.1.1","files":["/package.json","/packages/app-audit-logs/package.json","/packages/app-website-builder/package.json","/packages/common-audit-logs/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"file-loader","version":"6.2.0","files":["/packages/admin-ui/package.json"]},{"name":"folder-hash","version":"4.1.1","files":["/package.json"]},{"name":"fs-extra","version":"11.3.2","files":["/package.json"]},{"name":"get-stream","version":"3.0.0","files":["/package.json"]},{"name":"get-yarn-workspaces","version":"1.0.2","files":["/package.json"]},{"name":"git-cz","version":"1.8.4","files":["/package.json"]},{"name":"github-actions-wac","version":"2.0.0","files":["/package.json"]},{"name":"glob","version":"7.2.3","files":["/package.json"]},{"name":"graphql","version":"16.12.0","files":["/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-file-manager-aco/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-aco/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/testing/package.json"]},{"name":"graphql-request","version":"7.3.5","files":["/cypress-tests/package.json"]},{"name":"husky","version":"4.3.8","files":["/package.json"]},{"name":"identity-obj-proxy","version":"3.0.0","files":["/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"inquirer","version":"12.9.6","files":["/package.json"]},{"name":"jest-dynalite","version":"3.6.1","files":["/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-sync-system/package.json","/packages/data-migration/package.json","/packages/db-dynamodb/package.json","/packages/migrations/package.json","/packages/project-utils/package.json"]},{"name":"jest-extended","version":"6.0.0","files":["/package.json"]},{"name":"jsdom","version":"25.0.1","files":["/packages/lexical-converter/package.json"]},{"name":"jsonpack","version":"1.1.5","files":["/packages/api-file-manager-ddb/package.json"]},{"name":"lerna","version":"8.1.2","files":["/package.json"]},{"name":"lexical","version":"0.35.0","files":["/package.json"]},{"name":"lint-staged","version":"16.1.6","files":["/package.json"]},{"name":"listr","version":"0.14.3","files":["/package.json"]},{"name":"listr2","version":"5.0.8","files":["/packages/project-utils/package.json"]},{"name":"load-json-file","version":"6.2.0","files":["/package.json","/packages/project-utils/package.json"]},{"name":"lodash","version":"4.17.21","files":["/package.json","/cypress-tests/package.json"]},{"name":"longest","version":"2.0.1","files":["/package.json"]},{"name":"md5","version":"2.3.0","files":["/packages/api-security-cognito/package.json"]},{"name":"minimatch","version":"5.1.6","files":["/package.json"]},{"name":"mobx","version":"6.15.0","files":["/packages/form/package.json"]},{"name":"mobx-react-lite","version":"3.4.3","files":["/packages/form/package.json"]},{"name":"nanoid","version":"3.3.11","files":["/package.json","/cypress-tests/package.json"]},{"name":"ncp","version":"2.0.0","files":["/packages/ui/package.json"]},{"name":"next","version":"15.5.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"pino","version":"9.13.1","files":["/packages/logger/package.json","/packages/project-utils/package.json"]},{"name":"pino-pretty","version":"9.4.1","files":["/packages/project-utils/package.json"]},{"name":"postcss-loader","version":"8.2.0","files":["/packages/admin-ui/package.json"]},{"name":"prettier","version":"3.6.2","files":["/package.json","/packages/admin-ui/package.json","/packages/api-aco/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-website-builder/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"raw-loader","version":"4.0.2","files":["/packages/ui/package.json"]},{"name":"react","version":"18.2.0","files":["/package.json","/packages/lexical-nodes/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/package.json"]},{"name":"react-router","version":"7.9.1","files":["/packages/admin-ui/package.json"]},{"name":"react-router-dom","version":"7.9.1","files":["/packages/admin-ui/package.json"]},{"name":"rimraf","version":"6.0.1","files":["/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-background-tasks-ddb/package.json","/packages/api-background-tasks-os/package.json","/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json","/packages/api-security-okta/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/aws-sdk/package.json","/packages/cli-core/package.json","/packages/common-audit-logs/package.json","/packages/data-migration/package.json","/packages/db/package.json","/packages/db-dynamodb/package.json","/packages/error/package.json","/packages/feature-flags/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-client/package.json","/packages/handler-db/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/logger/package.json","/packages/plugins/package.json","/packages/project/package.json","/packages/pubsub/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/tasks/package.json","/packages/testing/package.json","/packages/theme/package.json","/packages/ui/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/wcp/package.json","/packages/webiny/package.json","/scripts/cli/package.json"]},{"name":"sass","version":"1.93.0","files":["/packages/admin-ui/package.json"]},{"name":"semver","version":"7.7.2","files":["/package.json"]},{"name":"storybook","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"ts-expect","version":"1.3.0","files":["/package.json"]},{"name":"tsx","version":"4.20.5","files":["/package.json"]},{"name":"ttypescript","version":"1.5.15","files":["/packages/api-file-manager-aco/package.json"]},{"name":"type-fest","version":"5.2.0","files":["/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-record-locking/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/project/package.json","/packages/tasks/package.json","/scripts/prepublishOnly/package.json"]},{"name":"typescript","version":"5.9.3","files":["/package.json","/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-background-tasks-ddb/package.json","/packages/api-background-tasks-os/package.json","/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-dynamodb-to-elasticsearch/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager/package.json","/packages/api-file-manager-aco/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-aco/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-headless-cms-es-tasks/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-headless-cms-tasks-ddb-es/package.json","/packages/api-headless-cms-workflows/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json","/packages/api-security-okta/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-utils/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/aws-sdk/package.json","/packages/cli-core/package.json","/packages/common-audit-logs/package.json","/packages/data-migration/package.json","/packages/db/package.json","/packages/db-dynamodb/package.json","/packages/error/package.json","/packages/feature/package.json","/packages/feature-flags/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-client/package.json","/packages/handler-db/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/ioc/package.json","/packages/logger/package.json","/packages/migrations/package.json","/packages/plugins/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pubsub/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/react-composition/package.json","/packages/shared-aco/package.json","/packages/tasks/package.json","/packages/testing/package.json","/packages/theme/package.json","/packages/ui/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/wcp/package.json","/packages/webiny/package.json","/packages/website-builder-nextjs/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json","/cypress-tests/package.json","/scripts/cli/package.json"]},{"name":"uniqid","version":"5.4.0","files":["/cypress-tests/package.json"]},{"name":"validator","version":"13.15.23","files":["/package.json"]},{"name":"verdaccio","version":"6.2.1","files":["/package.json"]},{"name":"vite-tsconfig-paths","version":"5.1.4","files":["/package.json"]},{"name":"vitest","version":"3.2.4","files":["/package.json","/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-core/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager-aco/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-headless-cms-es-tasks/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-headless-cms-workflows/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-security-cognito/package.json","/packages/api-sync-system/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/data-migration/package.json","/packages/db-dynamodb/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/ioc/package.json","/packages/lexical-converter/package.json","/packages/migrations/package.json","/packages/plugins/package.json","/packages/project-utils/package.json","/packages/pubsub/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/tasks/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/website-builder-nextjs/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"webpack","version":"5.101.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"write-json-file","version":"4.3.0","files":["/package.json","/packages/api-headless-cms/package.json"]},{"name":"yargs","version":"17.7.2","files":["/package.json","/packages/project-utils/package.json"]},{"name":"zod","version":"3.25.76","files":["/packages/ioc/package.json"]}],"peerDependencies":[{"name":"minimatch","version":"5.1.6","files":["/packages/ui/package.json"]},{"name":"react","version":"18.2.0","files":["/packages/app-audit-logs/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/packages/ui/package.json"]}],"resolutions":[{"name":"@emotion/react","version":"11.10.8","files":["/package.json"]},{"name":"@octokit/rest","version":"20.1.2","files":["/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/package.json"]},{"name":"@types/react-dom","version":"18.2.25","files":["/package.json"]},{"name":"react","version":"18.2.0","files":["/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/package.json"]},{"name":"semver","version":"7.7.2","files":["/package.json"]},{"name":"systeminformation","version":"5.23.18","files":["/package.json"]},{"name":"validator","version":"13.15.23","files":["/package.json"]}],"references":[{"name":"@types/hoist-non-react-statics","versions":[{"version":"3.3.7","files":[{"file":"/package.json","types":["dependencies"]}]}]},{"name":"@babel/cli","versions":[{"version":"7.28.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/code-frame","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@babel/compat-data","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/core","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/helper-define-polyfill-provider","versions":[{"version":"0.6.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/helper-environment-visitor","versions":[{"version":"7.24.7","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/parser","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-class-properties","versions":[{"version":"7.18.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-object-rest-spread","versions":[{"version":"7.20.7","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-throw-expressions","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-syntax-object-rest-spread","versions":[{"version":"7.8.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-transform-modules-commonjs","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-transform-runtime","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/preset-env","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/preset-react","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/preset-typescript","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/register","versions":[{"version":"7.28.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"@babel/runtime","versions":[{"version":"7.28.4","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/template","versions":[{"version":"7.27.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/traverse","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/types","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@commitlint/cli","versions":[{"version":"11.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@commitlint/config-conventional","versions":[{"version":"11.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@eslint/eslintrc","versions":[{"version":"3.3.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@eslint/js","versions":[{"version":"9.39.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@grpc/grpc-js","versions":[{"version":"1.14.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@lexical/code","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/hashtag","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/headless","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"@lexical/history","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/html","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"@lexical/list","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/mark","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/overflow","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/react","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/rich-text","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/selection","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/text","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]}]}]},{"name":"@lexical/utils","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@octokit/rest","versions":[{"version":"20.1.2","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"@types/fs-extra","versions":[{"version":"11.0.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/inquirer","versions":[{"version":"8.2.12","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/node","versions":[{"version":"22.18.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/react","versions":[{"version":"18.2.79","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-dom","versions":[{"version":"18.2.25","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"@typescript-eslint/eslint-plugin","versions":[{"version":"8.48.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@typescript-eslint/parser","versions":[{"version":"8.48.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@vitest/coverage-v8","versions":[{"version":"3.2.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@vitest/eslint-plugin","versions":[{"version":"1.4.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"adio","versions":[{"version":"2.0.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"axios","versions":[{"version":"1.12.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-loader","versions":[{"version":"10.0.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-dynamic-import-node","versions":[{"version":"2.3.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-macros","versions":[{"version":"3.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-module-resolver","versions":[{"version":"5.0.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]}]}]},{"name":"chalk","versions":[{"version":"4.1.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/aws-layers/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"cross-env","versions":[{"version":"5.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"cross-spawn","versions":[{"version":"6.0.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"deepmerge","versions":[{"version":"4.3.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"env-ci","versions":[{"version":"2.6.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint","versions":[{"version":"9.39.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"eslint-config-standard","versions":[{"version":"17.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-import-resolver-babel-module","versions":[{"version":"5.3.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-import","versions":[{"version":"2.32.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-lodash","versions":[{"version":"8.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-node","versions":[{"version":"11.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-promise","versions":[{"version":"7.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-react","versions":[{"version":"7.37.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-standard","versions":[{"version":"5.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"execa","versions":[{"version":"5.1.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/cli/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"folder-hash","versions":[{"version":"4.1.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"fs-extra","versions":[{"version":"11.3.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"get-stream","versions":[{"version":"3.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"get-yarn-workspaces","versions":[{"version":"1.0.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"git-cz","versions":[{"version":"1.8.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"github-actions-wac","versions":[{"version":"2.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"glob","versions":[{"version":"7.2.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"graphql","versions":[{"version":"16.12.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies","devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]}]}]},{"name":"husky","versions":[{"version":"4.3.8","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"inquirer","versions":[{"version":"12.9.6","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"jest-extended","versions":[{"version":"6.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"lerna","versions":[{"version":"8.1.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"lexical","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"lint-staged","versions":[{"version":"16.1.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"listr","versions":[{"version":"0.14.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"load-json-file","versions":[{"version":"6.2.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/telemetry/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"lodash","versions":[{"version":"4.17.21","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-aco/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-file-manager/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["dependencies"]},{"file":"/packages/api-mailer/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/api-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]},{"file":"/packages/i18n-react/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]},{"file":"/packages/validation/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"longest","versions":[{"version":"2.0.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"minimatch","versions":[{"version":"5.1.6","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]}]}]},{"name":"nanoid","versions":[{"version":"3.3.11","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"prettier","versions":[{"version":"3.6.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]}]}]},{"name":"react","versions":[{"version":"18.2.0","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["peerDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-websockets/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["peerDependencies"]},{"file":"/packages/i18n/package.json","types":["peerDependencies"]},{"file":"/packages/i18n-react/package.json","types":["peerDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["peerDependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"react-dom","versions":[{"version":"18.2.0","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-websockets/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"semver","versions":[{"version":"7.7.2","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]}]}]},{"name":"ts-expect","versions":[{"version":"1.3.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"tsx","versions":[{"version":"4.20.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"type-fest","versions":[{"version":"5.2.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["dependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/db/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"typescript","versions":[{"version":"5.9.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-os/package.json","types":["devDependencies"]},{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-dynamodb-to-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-security-okta/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-utils/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/app-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/aws-sdk/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/error/package.json","types":["devDependencies"]},{"file":"/packages/feature/package.json","types":["devDependencies"]},{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-client/package.json","types":["devDependencies"]},{"file":"/packages/handler-db/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/i18n-react/package.json","types":["devDependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/project-aws/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/shared-aco/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/wcp/package.json","types":["devDependencies"]},{"file":"/packages/webiny/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["devDependencies"]}]}]},{"name":"validator","versions":[{"version":"13.15.23","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"verdaccio","versions":[{"version":"6.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"vite-tsconfig-paths","versions":[{"version":"5.1.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"vitest","versions":[{"version":"3.2.4","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"write-json-file","versions":[{"version":"4.3.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"yargs","versions":[{"version":"17.7.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"systeminformation","versions":[{"version":"5.23.18","files":[{"file":"/package.json","types":["resolutions"]}]}]},{"name":"@emotion/react","versions":[{"version":"11.10.8","files":[{"file":"/package.json","types":["resolutions"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/fontawesome-svg-core","versions":[{"version":"1.3.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/react-fontawesome","versions":[{"version":"0.1.19","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]}]}]},{"name":"@minoru/react-dnd-treeview","versions":[{"version":"3.5.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"@monaco-editor/react","versions":[{"version":"4.7.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"@tanstack/react-table","versions":[{"version":"8.21.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"bytes","versions":[{"version":"3.1.2","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"class-variance-authority","versions":[{"version":"0.7.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"clsx","versions":[{"version":"2.1.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"cmdk","versions":[{"version":"1.1.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"mobx","versions":[{"version":"6.15.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-utils/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"monaco-editor","versions":[{"version":"0.53.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"radix-ui","versions":[{"version":"1.4.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"react-color","versions":[{"version":"2.19.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]}]}]},{"name":"react-custom-scrollbars","versions":[{"version":"4.2.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-dnd","versions":[{"version":"16.0.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"react-virtualized","versions":[{"version":"9.22.6","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"sonner","versions":[{"version":"2.0.7","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tailwind-merge","versions":[{"version":"2.6.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tailwindcss","versions":[{"version":"4.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"timeago-react","versions":[{"version":"3.0.7","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tw-animate-css","versions":[{"version":"1.4.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-solid-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@storybook/addon-a11y","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/addon-docs","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/addon-webpack5-compiler-babel","versions":[{"version":"3.0.6","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/react-webpack5","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@svgr/webpack","versions":[{"version":"6.5.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]}]}]},{"name":"@tailwindcss/postcss","versions":[{"version":"4.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@types/react-color","versions":[{"version":"2.17.12","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-custom-scrollbars","versions":[{"version":"4.0.13","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-virtualized","versions":[{"version":"9.22.3","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"css-loader","versions":[{"version":"7.1.2","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"eslint-plugin-storybook","versions":[{"version":"9.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"file-loader","versions":[{"version":"6.2.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"postcss-loader","versions":[{"version":"8.2.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"react-router","versions":[{"version":"7.9.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"react-router-dom","versions":[{"version":"7.9.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"rimraf","versions":[{"version":"6.0.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-os/package.json","types":["devDependencies"]},{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-security-okta/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/app-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/aws-sdk/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/error/package.json","types":["devDependencies"]},{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-client/package.json","types":["devDependencies"]},{"file":"/packages/handler-db/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/i18n-react/package.json","types":["devDependencies"]},{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/wcp/package.json","types":["devDependencies"]},{"file":"/packages/webiny/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["devDependencies"]}]}]},{"name":"sass","versions":[{"version":"1.93.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"storybook","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"zod","versions":[{"version":"3.25.76","files":[{"file":"/packages/api-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-file-manager/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["dependencies"]},{"file":"/packages/api-log/package.json","types":["dependencies"]},{"file":"/packages/api-mailer/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/api-websockets/package.json","types":["dependencies"]},{"file":"/packages/api-workflows/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"jsonwebtoken","versions":[{"version":"9.0.2","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["dependencies"]},{"file":"/packages/api-security-okta/package.json","types":["dependencies"]}]}]},{"name":"@types/jsonwebtoken","versions":[{"version":"9.0.10","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]}]}]},{"name":"@types/jwk-to-pem","versions":[{"version":"2.0.3","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]}]}]},{"name":"dataloader","versions":[{"version":"2.2.3","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["dependencies"]}]}]},{"name":"deep-equal","versions":[{"version":"2.2.3","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"jose","versions":[{"version":"5.10.0","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]}]}]},{"name":"md5","versions":[{"version":"2.3.0","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]}]}]},{"name":"@types/md5","versions":[{"version":"2.3.5","files":[{"file":"/packages/api-core/package.json","types":["devDependencies"]}]}]},{"name":"jest-dynalite","versions":[{"version":"3.6.1","files":[{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]}]}]},{"name":"p-retry","versions":[{"version":"7.0.0","files":[{"file":"/packages/api-dynamodb-to-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"@elastic/elasticsearch","versions":[{"version":"7.12.0","files":[{"file":"/packages/api-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]}]}]},{"name":"elastic-ts","versions":[{"version":"0.12.0","files":[{"file":"/packages/api-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]}]}]},{"name":"cache-control-parser","versions":[{"version":"2.0.6","files":[{"file":"/packages/api-file-manager/package.json","types":["dependencies"]}]}]},{"name":"object-hash","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager/package.json","types":["dependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"@types/object-hash","versions":[{"version":"3.0.6","files":[{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]}]}]},{"name":"ttypescript","versions":[{"version":"1.5.15","files":[{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]}]}]},{"name":"jsonpack","versions":[{"version":"1.1.5","files":[{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"mime","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"p-map","versions":[{"version":"7.0.3","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"p-reduce","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"sanitize-filename","versions":[{"version":"1.6.3","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"sharp","versions":[{"version":"0.34.4","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/merge","versions":[{"version":"9.1.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/schema","versions":[{"version":"10.0.30","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"dot-prop","versions":[{"version":"6.0.1","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"graphql-tag","versions":[{"version":"2.12.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"jsdom","versions":[{"version":"25.0.1","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]}]}]},{"name":"pluralize","versions":[{"version":"8.0.0","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"slugify","versions":[{"version":"1.6.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"@types/babel__code-frame","versions":[{"version":"7.0.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"@types/pluralize","versions":[{"version":"0.0.33","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"apollo-graphql","versions":[{"version":"0.9.7","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"dot-object","versions":[{"version":"2.1.5","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]}]}]},{"name":"@types/dot-object","versions":[{"version":"2.1.6","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]}]}]},{"name":"@types/jsonpack","versions":[{"version":"1.1.6","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]}]}]},{"name":"@faker-js/faker","versions":[{"version":"9.9.0","files":[{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]}]}]},{"name":"@smithy/node-http-handler","versions":[{"version":"2.5.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"archiver","versions":[{"version":"7.0.1","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"uniqid","versions":[{"version":"5.4.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/plugins/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"unzipper","versions":[{"version":"0.12.3","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"@types/adm-zip","versions":[{"version":"0.5.7","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"@types/archiver","versions":[{"version":"6.0.3","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"@types/unzipper","versions":[{"version":"0.10.11","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"adm-zip","versions":[{"version":"0.5.16","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"aws-sdk-client-mock","versions":[{"version":"4.1.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]}]}]},{"name":"crypto-js","versions":[{"version":"4.2.0","files":[{"file":"/packages/api-mailer/package.json","types":["dependencies"]}]}]},{"name":"nodemailer","versions":[{"version":"7.0.10","files":[{"file":"/packages/api-mailer/package.json","types":["dependencies"]}]}]},{"name":"@types/crypto-js","versions":[{"version":"4.2.2","files":[{"file":"/packages/api-mailer/package.json","types":["devDependencies"]}]}]},{"name":"@types/nodemailer","versions":[{"version":"6.4.19","files":[{"file":"/packages/api-mailer/package.json","types":["devDependencies"]}]}]},{"name":"@types/lodash","versions":[{"version":"4.17.20","files":[{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/cli/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/project-aws/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]}]}]},{"name":"@apollo/react-hooks","versions":[{"version":"3.1.5","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"@emotion/styled","versions":[{"version":"11.10.6","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]}]}]},{"name":"apollo-cache","versions":[{"version":"1.3.5","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"apollo-cache-inmemory","versions":[{"version":"1.6.6","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-client","versions":[{"version":"2.6.10","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"apollo-link","versions":[{"version":"1.2.14","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-context","versions":[{"version":"1.0.20","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-error","versions":[{"version":"1.1.13","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-http-common","versions":[{"version":"0.2.16","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-utilities","versions":[{"version":"1.3.4","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"boolean","versions":[{"version":"3.2.0","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"history","versions":[{"version":"5.3.0","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"invariant","versions":[{"version":"2.2.4","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"ts-invariant","versions":[{"version":"0.10.3","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"universal-router","versions":[{"version":"9.2.1","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"warning","versions":[{"version":"4.0.3","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"@types/universal-router","versions":[{"version":"8.0.0","files":[{"file":"/packages/app/package.json","types":["devDependencies"]}]}]},{"name":"@types/warning","versions":[{"version":"3.0.3","files":[{"file":"/packages/app/package.json","types":["devDependencies"]}]}]},{"name":"dot-prop-immutable","versions":[{"version":"2.1.1","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]}]}]},{"name":"mobx-react-lite","versions":[{"version":"3.4.3","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"pako","versions":[{"version":"2.1.0","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]}]}]},{"name":"react-hotkeyz","versions":[{"version":"1.0.4","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"store","versions":[{"version":"2.0.12","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@apollo/react-components","versions":[{"version":"3.1.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]}]}]},{"name":"@iconify/json","versions":[{"version":"2.2.386","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@types/mime","versions":[{"version":"2.0.3","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"case","versions":[{"version":"1.6.3","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"classnames","versions":[{"version":"2.5.1","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]}]}]},{"name":"emotion","versions":[{"version":"10.0.27","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]}]}]},{"name":"graphlib","versions":[{"version":"2.1.8","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"is-hotkey","versions":[{"version":"0.2.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"prop-types","versions":[{"version":"15.8.1","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"react-draggable","versions":[{"version":"4.5.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-resizable","versions":[{"version":"3.0.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-resizable-panels","versions":[{"version":"2.1.9","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-transition-group","versions":[{"version":"4.4.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"reset-css","versions":[{"version":"5.0.2","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"tinycolor2","versions":[{"version":"1.6.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"ts-morph","versions":[{"version":"24.0.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"unicode-emoji-json","versions":[{"version":"0.8.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@emotion/babel-plugin","versions":[{"version":"11.13.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@types/bytes","versions":[{"version":"3.1.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/graphlib","versions":[{"version":"2.1.12","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/is-hotkey","versions":[{"version":"0.1.10","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-resizable","versions":[{"version":"3.0.8","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-transition-group","versions":[{"version":"4.4.12","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/store","versions":[{"version":"2.0.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/tinycolor2","versions":[{"version":"1.4.6","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@auth0/auth0-react","versions":[{"version":"2.5.0","files":[{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]}]}]},{"name":"react-helmet","versions":[{"version":"6.1.0","files":[{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"@aws-amplify/auth","versions":[{"version":"5.6.15","files":[{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-auth-js","versions":[{"version":"5.11.0","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-react","versions":[{"version":"6.10.0","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-signin-widget","versions":[{"version":"5.16.1","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@types/react-helmet","versions":[{"version":"6.1.11","files":[{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-named-asset-import","versions":[{"version":"1.0.0-next.fb6e6f70","files":[{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"date-fns","versions":[{"version":"2.30.0","files":[{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"@apollo/react-common","versions":[{"version":"3.1.4","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"cropperjs","versions":[{"version":"1.6.2","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"dataurl-to-blob","versions":[{"version":"0.0.1","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"dayjs","versions":[{"version":"1.11.18","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"load-script","versions":[{"version":"1.0.0","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]}]}]},{"name":"react-butterfiles","versions":[{"version":"1.3.3","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-lazy-load","versions":[{"version":"3.1.14","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"@emotion/css","versions":[{"version":"11.10.6","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/fontawesome-common-types","versions":[{"version":"0.3.0","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-brands-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-regular-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"dnd-core","versions":[{"version":"16.0.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]}]}]},{"name":"raw.macro","versions":[{"version":"0.4.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-dnd-html5-backend","versions":[{"version":"16.0.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"use-deep-compare-effect","versions":[{"version":"1.8.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@material-design-icons/svg","versions":[{"version":"0.14.15","files":[{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/icons/package.json","types":["devDependencies"]}]}]},{"name":"crypto-hash","versions":[{"version":"3.1.0","files":[{"file":"/packages/app-record-locking/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-batch-http","versions":[{"version":"1.2.14","files":[{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]}]}]},{"name":"matcher","versions":[{"version":"5.0.0","files":[{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@types/deep-equal","versions":[{"version":"1.0.4","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"@types/pako","versions":[{"version":"2.0.4","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/platform","versions":[{"version":"1.3.6","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/randomcolor","versions":[{"version":"0.5.9","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-images","versions":[{"version":"0.5.3","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/resize-observer-browser","versions":[{"version":"0.1.11","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/aws-lambda","versions":[{"version":"8.10.152","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]},{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"cheerio","versions":[{"version":"1.1.2","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"srcset","versions":[{"version":"4.0.0","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-apigatewaymanagementapi","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudfront","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudwatch-events","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudwatch-logs","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cognito-identity-provider","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-dynamodb-streams","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-eventbridge","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-iam","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-iot","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-lambda","versions":[{"version":"3.942.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-s3","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-scheduler","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sfn","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sqs","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sts","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/credential-providers","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/lib-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/lib-storage","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/s3-presigned-post","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/s3-request-presigner","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/util-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/core","versions":[{"version":"1.6.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-react","versions":[{"version":"1.4.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-sass","versions":[{"version":"1.4.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-svgr","versions":[{"version":"1.2.2","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-type-check","versions":[{"version":"1.3.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rspack/core","versions":[{"version":"1.5.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@swc/plugin-emotion","versions":[{"version":"11.1.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@types/webpack-env","versions":[{"version":"1.18.8","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"chokidar","versions":[{"version":"4.0.3","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"fast-glob","versions":[{"version":"3.3.3","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"find-up","versions":[{"version":"5.0.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"process","versions":[{"version":"0.11.10","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"raw-loader","versions":[{"version":"4.0.2","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"react-refresh","versions":[{"version":"0.11.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"read-json-sync","versions":[{"version":"2.0.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"sass-loader","versions":[{"version":"16.0.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"style-loader","versions":[{"version":"3.3.4","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"url-loader","versions":[{"version":"4.1.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"utf-8-validate","versions":[{"version":"6.0.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"humanize-duration","versions":[{"version":"3.33.1","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"open","versions":[{"version":"10.2.0","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]}]}]},{"name":"ora","versions":[{"version":"4.1.1","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"@types/listr","versions":[{"version":"0.14.9","files":[{"file":"/packages/cli-core/package.json","types":["devDependencies"]}]}]},{"name":"js-yaml","versions":[{"version":"4.1.1","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"os","versions":[{"version":"0.1.2","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"uuid","versions":[{"version":"13.0.0","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]}]}]},{"name":"validate-npm-package-name","versions":[{"version":"6.0.2","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"yesno","versions":[{"version":"0.4.0","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"@types/js-yaml","versions":[{"version":"4.0.9","files":[{"file":"/packages/create-webiny-project/package.json","types":["devDependencies"]}]}]},{"name":"@types/validate-npm-package-name","versions":[{"version":"3.0.3","files":[{"file":"/packages/create-webiny-project/package.json","types":["devDependencies"]}]}]},{"name":"center-align","versions":[{"version":"1.0.1","files":[{"file":"/packages/data-migration/package.json","types":["dependencies"]}]}]},{"name":"pino-pretty","versions":[{"version":"9.4.1","files":[{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@types/center-align","versions":[{"version":"1.0.2","files":[{"file":"/packages/data-migration/package.json","types":["devDependencies"]}]}]},{"name":"@types/semver","versions":[{"version":"7.7.1","files":[{"file":"/packages/data-migration/package.json","types":["devDependencies"]}]}]},{"name":"dynamodb-toolbox","versions":[{"version":"0.9.5","files":[{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"fuse.js","versions":[{"version":"7.1.0","files":[{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"@types/is-number","versions":[{"version":"7.0.5","files":[{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]}]}]},{"name":"@types/uniqid","versions":[{"version":"5.3.4","files":[{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/react","versions":[{"version":"15.0.7","files":[{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/user-event","versions":[{"version":"14.6.1","files":[{"file":"/packages/form/package.json","types":["devDependencies"]}]}]},{"name":"@types/invariant","versions":[{"version":"2.2.37","files":[{"file":"/packages/form/package.json","types":["devDependencies"]}]}]},{"name":"ci-info","versions":[{"version":"4.3.0","files":[{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"@fastify/compress","versions":[{"version":"7.0.3","files":[{"file":"/packages/handler/package.json","types":["dependencies"]}]}]},{"name":"@fastify/cookie","versions":[{"version":"9.4.0","files":[{"file":"/packages/handler/package.json","types":["dependencies"]}]}]},{"name":"fastify","versions":[{"version":"4.29.1","files":[{"file":"/packages/handler/package.json","types":["dependencies"]},{"file":"/packages/handler-aws/package.json","types":["dependencies"]}]}]},{"name":"@fastify/aws-lambda","versions":[{"version":"4.1.0","files":[{"file":"/packages/handler-aws/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/resolvers-composition","versions":[{"version":"7.0.25","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/utils","versions":[{"version":"10.11.0","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"graphql-scalars","versions":[{"version":"1.25.0","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"accounting","versions":[{"version":"0.4.1","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"fecha","versions":[{"version":"2.3.3","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"short-hash","versions":[{"version":"1.0.0","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"@types/accounting","versions":[{"version":"0.4.5","files":[{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"@types/glob","versions":[{"version":"7.2.0","files":[{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"inversify","versions":[{"version":"6.2.2","files":[{"file":"/packages/ioc/package.json","types":["dependencies"]}]}]},{"name":"reflect-metadata","versions":[{"version":"0.2.2","files":[{"file":"/packages/ioc/package.json","types":["dependencies"]}]}]},{"name":"@types/jsdom","versions":[{"version":"21.1.7","files":[{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/prismjs","versions":[{"version":"1.26.5","files":[{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"react-style-object-to-css","versions":[{"version":"1.1.2","files":[{"file":"/packages/lexical-theme/package.json","types":["dependencies"]}]}]},{"name":"pino","versions":[{"version":"9.13.1","files":[{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"debounce","versions":[{"version":"1.2.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"dotenv","versions":[{"version":"8.6.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"exit-hook","versions":[{"version":"4.0.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"graphql-request","versions":[{"version":"7.3.5","files":[{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"mqtt","versions":[{"version":"5.14.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"neverthrow","versions":[{"version":"8.2.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"react-test-renderer","versions":[{"version":"18.3.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"replace-in-path","versions":[{"version":"1.1.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"serialize-error","versions":[{"version":"12.0.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"@types/debounce","versions":[{"version":"1.2.4","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/humanize-duration","versions":[{"version":"3.27.4","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-test-renderer","versions":[{"version":"18.3.1","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/read-json-sync","versions":[{"version":"2.0.3","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@pulumi/aws","versions":[{"version":"7.12.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"@pulumi/pulumi","versions":[{"version":"3.209.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"@pulumi/random","versions":[{"version":"4.18.4","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"core-js","versions":[{"version":"3.45.1","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"cross-fetch","versions":[{"version":"3.2.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"regenerator-runtime","versions":[{"version":"0.14.1","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"@types/ncp","versions":[{"version":"2.0.8","files":[{"file":"/packages/project-aws/package.json","types":["devDependencies"]}]}]},{"name":"listr2","versions":[{"version":"5.0.8","files":[{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"decompress","versions":[{"version":"4.2.1","files":[{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"tar","versions":[{"version":"6.2.1","files":[{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"identity-obj-proxy","versions":[{"version":"3.0.0","files":[{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]}]}]},{"name":"cli-table3","versions":[{"version":"0.6.5","files":[{"file":"/packages/system-requirements/package.json","types":["dependencies"]}]}]},{"name":"object-merge-advanced","versions":[{"version":"12.1.0","files":[{"file":"/packages/tasks/package.json","types":["dependencies"]}]}]},{"name":"object-sizeof","versions":[{"version":"2.6.5","files":[{"file":"/packages/tasks/package.json","types":["dependencies"]}]}]},{"name":"jsesc","versions":[{"version":"3.1.0","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"strip-ansi","versions":[{"version":"6.0.1","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"wts-client","versions":[{"version":"2.0.0","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"ncp","versions":[{"version":"2.0.0","files":[{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@noble/hashes","versions":[{"version":"2.0.0","files":[{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"bson-objectid","versions":[{"version":"2.0.4","files":[{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"nanoid-dictionary","versions":[{"version":"4.3.0","files":[{"file":"/packages/utils/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"isnumeric","versions":[{"version":"0.3.3","files":[{"file":"/packages/validation/package.json","types":["dependencies"]}]}]},{"name":"postcss","versions":[{"version":"8.5.6","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["dependencies"]}]}]},{"name":"postcss-import","versions":[{"version":"16.1.1","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["dependencies"]}]}]},{"name":"@types/postcss-import","versions":[{"version":"14.0.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"next","versions":[{"version":"15.5.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"webpack","versions":[{"version":"5.101.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"csstype","versions":[{"version":"3.1.3","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"fast-json-patch","versions":[{"version":"3.1.1","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"fast-json-stable-stringify","versions":[{"version":"2.1.0","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@4tw/cypress-drag-drop","versions":[{"version":"1.8.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/cypress","versions":[{"version":"10.1.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"amazon-cognito-identity-js","versions":[{"version":"4.6.3","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress","versions":[{"version":"13.17.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-image-snapshot","versions":[{"version":"4.0.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-mailosaur","versions":[{"version":"2.17.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-wait-until","versions":[{"version":"1.7.2","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"del","versions":[{"version":"6.1.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"@types/folder-hash","versions":[{"version":"4.0.4","files":[{"file":"/scripts/buildPackages/package.json","types":["devDependencies"]}]}]},{"name":"@types/yargs","versions":[{"version":"17.0.33","files":[{"file":"/scripts/buildPackages/package.json","types":["devDependencies"]}]}]},{"name":"cli-progress","versions":[{"version":"3.12.0","files":[{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"p-limit","versions":[{"version":"7.1.1","files":[{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"@types/cli-progress","versions":[{"version":"3.11.6","files":[{"file":"/scripts/cjsToEsm/package.json","types":["devDependencies"]}]}]}]} +{"dependencies":[{"name":"@apollo/react-common","version":"3.1.4","files":["/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"@apollo/react-components","version":"3.1.5","files":["/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-mailer/package.json"]},{"name":"@apollo/react-hooks","version":"3.1.5","files":["/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-record-locking/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"@auth0/auth0-react","version":"2.5.0","files":["/packages/app-admin-auth0/package.json"]},{"name":"@aws-amplify/auth","version":"5.6.15","files":["/packages/app-admin-cognito/package.json","/packages/app-cognito-authenticator/package.json"]},{"name":"@aws-sdk/client-apigatewaymanagementapi","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudfront","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudwatch-events","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cloudwatch-logs","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-cognito-identity-provider","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-dynamodb-streams","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-eventbridge","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-iam","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-iot","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-lambda","version":"3.942.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-s3","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-scheduler","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sfn","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sqs","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/client-sts","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/credential-providers","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/lib-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/lib-storage","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/s3-presigned-post","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/s3-request-presigner","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@aws-sdk/util-dynamodb","version":"3.940.0","files":["/packages/aws-sdk/package.json"]},{"name":"@babel/code-frame","version":"7.27.1","files":["/packages/api-headless-cms/package.json"]},{"name":"@babel/core","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-env","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-react","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/preset-typescript","version":"7.28.5","files":["/packages/build-tools/package.json"]},{"name":"@babel/runtime","version":"7.28.4","files":["/packages/build-tools/package.json"]},{"name":"@elastic/elasticsearch","version":"7.12.0","files":["/packages/api-elasticsearch/package.json","/packages/data-migration/package.json","/packages/migrations/package.json"]},{"name":"@emotion/css","version":"11.10.6","files":["/packages/app-headless-cms/package.json"]},{"name":"@emotion/react","version":"11.10.8","files":["/packages/app-admin/package.json","/packages/app-audit-logs/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-theme/package.json","/packages/theme/package.json"]},{"name":"@emotion/styled","version":"11.10.6","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"@fastify/aws-lambda","version":"4.1.0","files":["/packages/handler-aws/package.json"]},{"name":"@fastify/compress","version":"7.0.3","files":["/packages/handler/package.json"]},{"name":"@fastify/cookie","version":"9.4.0","files":["/packages/handler/package.json"]},{"name":"@fortawesome/fontawesome-common-types","version":"0.3.0","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/fontawesome-svg-core","version":"1.3.0","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-workflows/package.json"]},{"name":"@fortawesome/free-brands-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/free-regular-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/free-solid-svg-icons","version":"6.7.2","files":["/packages/app-headless-cms/package.json"]},{"name":"@fortawesome/react-fontawesome","version":"0.1.19","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json"]},{"name":"@graphql-tools/merge","version":"9.1.6","files":["/packages/api-headless-cms/package.json","/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/resolvers-composition","version":"7.0.25","files":["/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/schema","version":"10.0.30","files":["/packages/api-headless-cms/package.json","/packages/handler-graphql/package.json"]},{"name":"@graphql-tools/utils","version":"10.11.0","files":["/packages/handler-graphql/package.json"]},{"name":"@iconify/json","version":"2.2.386","files":["/packages/app-admin/package.json"]},{"name":"@lexical/code","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/hashtag","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/headless","version":"0.35.0","files":["/packages/lexical-converter/package.json"]},{"name":"@lexical/history","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/html","version":"0.35.0","files":["/packages/lexical-converter/package.json"]},{"name":"@lexical/list","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/mark","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/overflow","version":"0.35.0","files":["/packages/lexical-nodes/package.json"]},{"name":"@lexical/react","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/rich-text","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/selection","version":"0.35.0","files":["/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/lexical-nodes/package.json"]},{"name":"@lexical/text","version":"0.35.0","files":["/packages/lexical-editor/package.json"]},{"name":"@lexical/utils","version":"0.35.0","files":["/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json"]},{"name":"@material-design-icons/svg","version":"0.14.15","files":["/packages/app-headless-cms-scheduler/package.json"]},{"name":"@minoru/react-dnd-treeview","version":"3.5.3","files":["/packages/admin-ui/package.json"]},{"name":"@monaco-editor/react","version":"4.7.0","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@noble/hashes","version":"2.0.0","files":["/packages/utils/package.json"]},{"name":"@okta/okta-auth-js","version":"5.11.0","files":["/packages/app-admin-okta/package.json"]},{"name":"@okta/okta-react","version":"6.10.0","files":["/packages/app-admin-okta/package.json"]},{"name":"@okta/okta-signin-widget","version":"5.16.1","files":["/packages/app-admin-okta/package.json"]},{"name":"@pulumi/aws","version":"7.12.0","files":["/packages/project-aws/package.json","/packages/pulumi-sdk/package.json"]},{"name":"@pulumi/pulumi","version":"3.209.0","files":["/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json"]},{"name":"@pulumi/random","version":"4.18.4","files":["/packages/project-aws/package.json"]},{"name":"@rsbuild/core","version":"1.6.0","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-react","version":"1.4.1","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-sass","version":"1.4.0","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-svgr","version":"1.2.2","files":["/packages/build-tools/package.json"]},{"name":"@rsbuild/plugin-type-check","version":"1.3.0","files":["/packages/build-tools/package.json"]},{"name":"@rspack/core","version":"1.5.5","files":["/packages/build-tools/package.json"]},{"name":"@smithy/node-http-handler","version":"2.5.0","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@svgr/webpack","version":"6.5.1","files":["/packages/app-admin/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/build-tools/package.json","/packages/ui/package.json"]},{"name":"@swc/plugin-emotion","version":"11.1.0","files":["/packages/build-tools/package.json"]},{"name":"@tailwindcss/postcss","version":"4.1.16","files":["/packages/build-tools/package.json"]},{"name":"@tanstack/react-table","version":"8.21.3","files":["/packages/admin-ui/package.json"]},{"name":"@types/aws-lambda","version":"8.10.152","files":["/packages/aws-helpers/package.json","/packages/aws-sdk/package.json"]},{"name":"@types/hoist-non-react-statics","version":"3.3.7","files":["/package.json"]},{"name":"@types/mime","version":"2.0.3","files":["/packages/app-admin/package.json"]},{"name":"@types/prismjs","version":"1.26.5","files":["/packages/lexical-nodes/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-ui/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"@types/webpack-env","version":"1.18.8","files":["/packages/build-tools/package.json"]},{"name":"accounting","version":"0.4.1","files":["/packages/i18n/package.json"]},{"name":"apollo-cache","version":"1.3.5","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"apollo-cache-inmemory","version":"1.6.6","files":["/packages/app/package.json"]},{"name":"apollo-client","version":"2.6.10","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"apollo-link","version":"1.2.14","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"apollo-link-batch-http","version":"1.2.14","files":["/packages/app-serverless-cms/package.json"]},{"name":"apollo-link-context","version":"1.0.20","files":["/packages/app/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-graphql-playground/package.json"]},{"name":"apollo-link-error","version":"1.1.13","files":["/packages/app/package.json"]},{"name":"apollo-link-http-common","version":"0.2.16","files":["/packages/app/package.json"]},{"name":"apollo-utilities","version":"1.3.4","files":["/packages/app/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"archiver","version":"7.0.1","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"boolean","version":"3.2.0","files":["/packages/app/package.json","/packages/handler-graphql/package.json"]},{"name":"bson-objectid","version":"2.0.4","files":["/packages/utils/package.json"]},{"name":"bytes","version":"3.1.2","files":["/packages/admin-ui/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-sync-system/package.json","/packages/app/package.json","/packages/app-file-manager/package.json"]},{"name":"cache-control-parser","version":"2.0.6","files":["/packages/api-file-manager/package.json"]},{"name":"case","version":"1.6.3","files":["/packages/app-admin/package.json","/packages/project/package.json"]},{"name":"center-align","version":"1.0.1","files":["/packages/data-migration/package.json"]},{"name":"chalk","version":"4.1.2","files":["/packages/aws-layers/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/data-migration/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/system-requirements/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json","/scripts/cli/package.json"]},{"name":"cheerio","version":"1.1.2","files":["/packages/aws-helpers/package.json","/packages/lexical-converter/package.json"]},{"name":"chokidar","version":"4.0.3","files":["/packages/build-tools/package.json","/packages/project/package.json"]},{"name":"ci-info","version":"4.3.0","files":["/packages/global-config/package.json","/packages/project/package.json","/packages/telemetry/package.json"]},{"name":"class-variance-authority","version":"0.7.1","files":["/packages/admin-ui/package.json"]},{"name":"classnames","version":"2.5.1","files":["/packages/app-admin/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/ui/package.json"]},{"name":"cli-progress","version":"3.12.0","files":["/scripts/cjsToEsm/package.json"]},{"name":"cli-table3","version":"0.6.5","files":["/packages/system-requirements/package.json"]},{"name":"clsx","version":"2.1.1","files":["/packages/admin-ui/package.json"]},{"name":"cmdk","version":"1.1.1","files":["/packages/admin-ui/package.json"]},{"name":"core-js","version":"3.45.1","files":["/packages/project-aws/package.json"]},{"name":"cropperjs","version":"1.6.2","files":["/packages/app-file-manager/package.json"]},{"name":"cross-fetch","version":"3.2.0","files":["/packages/project-aws/package.json"]},{"name":"crypto-hash","version":"3.1.0","files":["/packages/app-record-locking/package.json"]},{"name":"crypto-js","version":"4.2.0","files":["/packages/api-mailer/package.json"]},{"name":"css-loader","version":"7.1.2","files":["/packages/build-tools/package.json"]},{"name":"csstype","version":"3.1.3","files":["/packages/website-builder-sdk/package.json"]},{"name":"dataloader","version":"2.2.3","files":["/packages/api-core/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json"]},{"name":"dataurl-to-blob","version":"0.0.1","files":["/packages/app-file-manager/package.json"]},{"name":"date-fns","version":"2.30.0","files":["/packages/app-audit-logs/package.json","/packages/db-dynamodb/package.json"]},{"name":"dayjs","version":"1.11.18","files":["/packages/app-file-manager/package.json"]},{"name":"debounce","version":"1.2.1","files":["/packages/project/package.json"]},{"name":"decompress","version":"4.2.1","files":["/packages/pulumi-sdk/package.json"]},{"name":"deep-equal","version":"2.2.3","files":["/packages/api-core/package.json","/packages/api-security-cognito/package.json","/packages/app-website-builder/package.json","/packages/tasks/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"deepmerge","version":"4.3.1","files":["/packages/website-builder-sdk/package.json"]},{"name":"dnd-core","version":"16.0.1","files":["/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json"]},{"name":"dot-object","version":"2.1.5","files":["/packages/api-headless-cms-ddb/package.json"]},{"name":"dot-prop","version":"6.0.1","files":["/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb/package.json","/packages/db-dynamodb/package.json"]},{"name":"dot-prop-immutable","version":"2.1.1","files":["/packages/app-aco/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-mailer/package.json"]},{"name":"dotenv","version":"8.6.0","files":["/packages/project/package.json"]},{"name":"dynamodb-toolbox","version":"0.9.5","files":["/packages/db-dynamodb/package.json"]},{"name":"elastic-ts","version":"0.12.0","files":["/packages/api-elasticsearch/package.json"]},{"name":"emotion","version":"10.0.27","files":["/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-website-builder/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/lexical-theme/package.json"]},{"name":"eslint","version":"9.39.1","files":["/packages/build-tools/package.json"]},{"name":"execa","version":"5.1.1","files":["/packages/cli/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project/package.json","/packages/pulumi-sdk/package.json","/packages/system-requirements/package.json","/scripts/buildPackages/package.json"]},{"name":"exit-hook","version":"4.0.0","files":["/packages/project/package.json"]},{"name":"fast-glob","version":"3.3.3","files":["/packages/build-tools/package.json","/packages/project/package.json","/scripts/cjsToEsm/package.json"]},{"name":"fast-json-patch","version":"3.1.1","files":["/packages/website-builder-sdk/package.json"]},{"name":"fast-json-stable-stringify","version":"2.1.0","files":["/packages/website-builder-sdk/package.json"]},{"name":"fastify","version":"4.29.1","files":["/packages/handler/package.json","/packages/handler-aws/package.json"]},{"name":"fecha","version":"2.3.3","files":["/packages/i18n/package.json"]},{"name":"find-up","version":"5.0.0","files":["/packages/build-tools/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/scripts/prepublishOnly/package.json","/scripts/cli/package.json","/scripts/cjsToEsm/package.json"]},{"name":"folder-hash","version":"4.1.1","files":["/scripts/buildPackages/package.json"]},{"name":"fs-extra","version":"11.3.2","files":["/packages/build-tools/package.json","/packages/create-webiny-project/package.json","/packages/pulumi-sdk/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"fuse.js","version":"7.1.0","files":["/packages/db-dynamodb/package.json"]},{"name":"get-yarn-workspaces","version":"1.0.2","files":["/packages/build-tools/package.json","/packages/cli-core/package.json","/packages/project-utils/package.json","/scripts/prepublishOnly/package.json"]},{"name":"glob","version":"7.2.3","files":["/packages/cli-core/package.json","/packages/i18n/package.json"]},{"name":"graphlib","version":"2.1.8","files":["/packages/app-admin/package.json"]},{"name":"graphql","version":"16.12.0","files":["/packages/api-headless-cms/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-mailer/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/handler-graphql/package.json"]},{"name":"graphql-request","version":"7.3.5","files":["/packages/project/package.json"]},{"name":"graphql-scalars","version":"1.25.0","files":["/packages/handler-graphql/package.json"]},{"name":"graphql-tag","version":"2.12.6","files":["/packages/api-headless-cms/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security-access-management/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/handler-graphql/package.json"]},{"name":"history","version":"5.3.0","files":["/packages/app/package.json","/packages/app-admin/package.json"]},{"name":"humanize-duration","version":"3.33.1","files":["/packages/cli-core/package.json","/packages/project/package.json"]},{"name":"inquirer","version":"12.9.6","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json"]},{"name":"invariant","version":"2.2.4","files":["/packages/app/package.json","/packages/project-aws/package.json"]},{"name":"inversify","version":"6.2.2","files":["/packages/ioc/package.json"]},{"name":"is-hotkey","version":"0.2.0","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"isnumeric","version":"0.3.3","files":["/packages/validation/package.json"]},{"name":"jose","version":"5.10.0","files":["/packages/api-core/package.json"]},{"name":"js-yaml","version":"4.1.1","files":["/packages/create-webiny-project/package.json"]},{"name":"jsdom","version":"25.0.1","files":["/packages/api-headless-cms/package.json"]},{"name":"jsesc","version":"3.1.0","files":["/packages/telemetry/package.json"]},{"name":"jsonpack","version":"1.1.5","files":["/packages/utils/package.json"]},{"name":"jsonwebtoken","version":"9.0.2","files":["/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-security-auth0/package.json","/packages/api-security-okta/package.json"]},{"name":"lexical","version":"0.35.0","files":["/packages/lexical-converter/package.json","/packages/lexical-editor/package.json","/packages/lexical-nodes/package.json","/packages/lexical-theme/package.json","/packages/website-builder-sdk/package.json"]},{"name":"listr","version":"0.14.3","files":["/packages/create-webiny-project/package.json"]},{"name":"listr2","version":"5.0.8","files":["/scripts/buildPackages/package.json"]},{"name":"load-json-file","version":"6.2.0","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/global-config/package.json","/packages/telemetry/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"load-script","version":"1.0.0","files":["/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json"]},{"name":"lodash","version":"4.17.21","files":["/packages/admin-ui/package.json","/packages/api-aco/package.json","/packages/api-core/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-mailer/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security-access-management/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/build-tools/package.json","/packages/db-dynamodb/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/lexical-editor/package.json","/packages/migrations/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/project-utils/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/tasks/package.json","/packages/ui/package.json","/packages/validation/package.json","/packages/website-builder-sdk/package.json"]},{"name":"matcher","version":"5.0.0","files":["/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"md5","version":"2.3.0","files":["/packages/api-core/package.json"]},{"name":"mime","version":"3.0.0","files":["/packages/api-file-manager-s3/package.json","/packages/app-file-manager/package.json","/packages/project-aws/package.json"]},{"name":"minimatch","version":"5.1.6","files":["/packages/admin-ui/package.json","/packages/api-core/package.json","/packages/app/package.json","/packages/app-file-manager/package.json","/packages/app-security/package.json","/packages/data-migration/package.json","/packages/project/package.json"]},{"name":"mobx","version":"6.15.0","files":["/packages/admin-ui/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-trash-bin/package.json","/packages/app-utils/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"mobx-react-lite","version":"3.4.3","files":["/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json","/packages/website-builder-react/package.json"]},{"name":"monaco-editor","version":"0.53.0","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json"]},{"name":"mqtt","version":"5.14.1","files":["/packages/project/package.json"]},{"name":"nanoid","version":"3.3.11","files":["/packages/app/package.json","/packages/react-properties/package.json","/packages/utils/package.json","/packages/website-builder-sdk/package.json"]},{"name":"nanoid-dictionary","version":"4.3.0","files":["/packages/utils/package.json","/packages/website-builder-sdk/package.json"]},{"name":"neverthrow","version":"8.2.0","files":["/packages/project/package.json"]},{"name":"nodemailer","version":"7.0.10","files":["/packages/api-mailer/package.json"]},{"name":"object-hash","version":"3.0.0","files":["/packages/api-file-manager/package.json","/packages/api-file-manager-s3/package.json"]},{"name":"object-merge-advanced","version":"12.1.0","files":["/packages/tasks/package.json"]},{"name":"object-sizeof","version":"2.6.5","files":["/packages/tasks/package.json"]},{"name":"open","version":"10.2.0","files":["/packages/cli-core/package.json"]},{"name":"ora","version":"4.1.1","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/project-aws/package.json"]},{"name":"os","version":"0.1.2","files":["/packages/create-webiny-project/package.json"]},{"name":"p-limit","version":"7.1.1","files":["/scripts/cjsToEsm/package.json"]},{"name":"p-map","version":"7.0.3","files":["/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json"]},{"name":"p-reduce","version":"3.0.0","files":["/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json"]},{"name":"p-retry","version":"7.0.0","files":["/packages/api-dynamodb-to-elasticsearch/package.json","/packages/app-file-manager-s3/package.json","/packages/project/package.json","/packages/utils/package.json"]},{"name":"pako","version":"2.1.0","files":["/packages/app-aco/package.json"]},{"name":"pino","version":"9.13.1","files":["/packages/website-builder-sdk/package.json","/scripts/cli/package.json"]},{"name":"pino-pretty","version":"9.4.1","files":["/packages/data-migration/package.json","/packages/website-builder-sdk/package.json"]},{"name":"pluralize","version":"8.0.0","files":["/packages/api-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"postcss","version":"8.5.6","files":["/packages/website-builder-nextjs/package.json"]},{"name":"postcss-import","version":"16.1.1","files":["/packages/website-builder-nextjs/package.json"]},{"name":"postcss-loader","version":"8.2.0","files":["/packages/build-tools/package.json"]},{"name":"process","version":"0.11.10","files":["/packages/build-tools/package.json"]},{"name":"prop-types","version":"15.8.1","files":["/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-mailer/package.json","/packages/app-website-builder/package.json","/packages/app-workflows/package.json"]},{"name":"radix-ui","version":"1.4.3","files":["/packages/admin-ui/package.json"]},{"name":"raw-loader","version":"4.0.2","files":["/packages/build-tools/package.json"]},{"name":"raw.macro","version":"0.4.2","files":["/packages/app-headless-cms/package.json"]},{"name":"react","version":"18.2.0","files":["/packages/admin-ui/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/website-builder-react/package.json"]},{"name":"react-butterfiles","version":"1.3.3","files":["/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-color","version":"2.19.3","files":["/packages/admin-ui/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"react-custom-scrollbars","version":"4.2.1","files":["/packages/admin-ui/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-dnd","version":"16.0.1","files":["/packages/admin-ui/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"react-dnd-html5-backend","version":"16.0.1","files":["/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/build-tools/package.json","/packages/lexical-editor/package.json","/packages/lexical-editor-actions/package.json","/packages/react-composition/package.json","/packages/website-builder-react/package.json"]},{"name":"react-draggable","version":"4.5.0","files":["/packages/app-admin/package.json"]},{"name":"react-helmet","version":"6.1.0","files":["/packages/app-admin-auth0/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-security-access-management/package.json","/packages/app-workflows/package.json"]},{"name":"react-hotkeyz","version":"1.0.4","files":["/packages/app-aco/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json"]},{"name":"react-lazy-load","version":"3.1.14","files":["/packages/app-file-manager/package.json"]},{"name":"react-refresh","version":"0.11.0","files":["/packages/build-tools/package.json"]},{"name":"react-resizable","version":"3.0.5","files":["/packages/app-admin/package.json"]},{"name":"react-resizable-panels","version":"2.1.9","files":["/packages/app-admin/package.json"]},{"name":"react-style-object-to-css","version":"1.1.2","files":["/packages/lexical-theme/package.json"]},{"name":"react-test-renderer","version":"18.3.1","files":["/packages/project/package.json"]},{"name":"react-transition-group","version":"4.4.5","files":["/packages/app-admin/package.json"]},{"name":"react-virtualized","version":"9.22.6","files":["/packages/admin-ui/package.json","/packages/app-admin/package.json"]},{"name":"read-json-sync","version":"2.0.1","files":["/packages/build-tools/package.json","/packages/project/package.json","/packages/pulumi-sdk/package.json","/scripts/cjsToEsm/package.json"]},{"name":"reflect-metadata","version":"0.2.2","files":["/packages/ioc/package.json"]},{"name":"regenerator-runtime","version":"0.14.1","files":["/packages/project-aws/package.json"]},{"name":"replace-in-path","version":"1.1.0","files":["/packages/project/package.json"]},{"name":"reset-css","version":"5.0.2","files":["/packages/app-admin/package.json"]},{"name":"rimraf","version":"6.0.1","files":["/packages/build-tools/package.json","/packages/create-webiny-project/package.json"]},{"name":"sanitize-filename","version":"1.6.3","files":["/packages/api-file-manager-s3/package.json"]},{"name":"sass","version":"1.93.0","files":["/packages/build-tools/package.json"]},{"name":"sass-loader","version":"16.0.5","files":["/packages/build-tools/package.json"]},{"name":"semver","version":"7.7.2","files":["/packages/api-sync-system/package.json","/packages/cli-core/package.json","/packages/data-migration/package.json","/packages/pulumi-sdk/package.json","/packages/system-requirements/package.json"]},{"name":"serialize-error","version":"12.0.0","files":["/packages/project/package.json","/scripts/buildPackages/package.json"]},{"name":"sharp","version":"0.34.4","files":["/packages/api-file-manager-s3/package.json"]},{"name":"short-hash","version":"1.0.0","files":["/packages/i18n/package.json"]},{"name":"slugify","version":"1.6.6","files":["/packages/api-headless-cms/package.json","/packages/app-aco/package.json","/packages/app-website-builder/package.json"]},{"name":"sonner","version":"2.0.7","files":["/packages/admin-ui/package.json"]},{"name":"srcset","version":"4.0.0","files":["/packages/aws-helpers/package.json"]},{"name":"store","version":"2.0.12","files":["/packages/app-aco/package.json","/packages/app-admin/package.json"]},{"name":"strip-ansi","version":"6.0.1","files":["/packages/telemetry/package.json"]},{"name":"style-loader","version":"3.3.4","files":["/packages/build-tools/package.json"]},{"name":"tailwind-merge","version":"2.6.0","files":["/packages/admin-ui/package.json"]},{"name":"tailwindcss","version":"4.1.16","files":["/packages/admin-ui/package.json"]},{"name":"tar","version":"6.2.1","files":["/packages/pulumi-sdk/package.json"]},{"name":"timeago-react","version":"3.0.7","files":["/packages/admin-ui/package.json"]},{"name":"tinycolor2","version":"1.6.0","files":["/packages/app-admin/package.json"]},{"name":"ts-invariant","version":"0.10.3","files":["/packages/app/package.json"]},{"name":"ts-morph","version":"24.0.0","files":["/packages/app-admin/package.json","/packages/build-tools/package.json","/packages/project/package.json","/scripts/cjsToEsm/package.json"]},{"name":"tsx","version":"4.20.5","files":["/packages/build-tools/package.json","/packages/cli/package.json","/packages/project/package.json","/scripts/buildPackages/package.json","/scripts/cjsToEsm/package.json"]},{"name":"tw-animate-css","version":"1.4.0","files":["/packages/admin-ui/package.json"]},{"name":"type-fest","version":"5.2.0","files":["/packages/admin-ui/package.json","/packages/api-websockets/package.json","/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/db/package.json","/scripts/cli/package.json"]},{"name":"typescript","version":"5.9.3","files":["/packages/build-tools/package.json"]},{"name":"unicode-emoji-json","version":"0.8.0","files":["/packages/app-admin/package.json"]},{"name":"uniqid","version":"5.4.0","files":["/packages/api-headless-cms-import-export/package.json","/packages/plugins/package.json"]},{"name":"universal-router","version":"9.2.1","files":["/packages/app/package.json"]},{"name":"unzipper","version":"0.12.3","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"url-loader","version":"4.1.1","files":["/packages/build-tools/package.json"]},{"name":"use-deep-compare-effect","version":"1.8.1","files":["/packages/app-headless-cms/package.json"]},{"name":"utf-8-validate","version":"6.0.5","files":["/packages/build-tools/package.json"]},{"name":"uuid","version":"13.0.0","files":["/packages/create-webiny-project/package.json","/packages/global-config/package.json"]},{"name":"validate-npm-package-name","version":"6.0.2","files":["/packages/create-webiny-project/package.json"]},{"name":"vitest","version":"3.2.4","files":["/packages/api-headless-cms-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"warning","version":"4.0.3","files":["/packages/app/package.json"]},{"name":"write-json-file","version":"4.3.0","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/global-config/package.json","/scripts/buildPackages/package.json","/scripts/prepublishOnly/package.json"]},{"name":"wts-client","version":"2.0.0","files":["/packages/telemetry/package.json"]},{"name":"yargs","version":"17.7.2","files":["/packages/cli-core/package.json","/packages/create-webiny-project/package.json","/packages/i18n/package.json","/scripts/buildPackages/package.json","/scripts/cli/package.json"]},{"name":"yesno","version":"0.4.0","files":["/packages/create-webiny-project/package.json"]},{"name":"zod","version":"3.25.76","files":["/packages/api-audit-logs/package.json","/packages/api-core/package.json","/packages/api-file-manager/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-scheduler/package.json","/packages/api-sync-system/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-audit-logs/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/cli-core/package.json","/packages/handler-graphql/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/tasks/package.json","/packages/utils/package.json"]}],"devDependencies":[{"name":"@4tw/cypress-drag-drop","version":"1.8.1","files":["/cypress-tests/package.json"]},{"name":"@babel/cli","version":"7.28.3","files":["/package.json"]},{"name":"@babel/code-frame","version":"7.27.1","files":["/package.json"]},{"name":"@babel/compat-data","version":"7.28.5","files":["/package.json"]},{"name":"@babel/core","version":"7.28.5","files":["/package.json"]},{"name":"@babel/helper-define-polyfill-provider","version":"0.6.5","files":["/package.json"]},{"name":"@babel/helper-environment-visitor","version":"7.24.7","files":["/package.json"]},{"name":"@babel/parser","version":"7.28.5","files":["/package.json"]},{"name":"@babel/plugin-proposal-class-properties","version":"7.18.6","files":["/package.json"]},{"name":"@babel/plugin-proposal-object-rest-spread","version":"7.20.7","files":["/package.json"]},{"name":"@babel/plugin-proposal-throw-expressions","version":"7.27.1","files":["/package.json"]},{"name":"@babel/plugin-syntax-object-rest-spread","version":"7.8.3","files":["/package.json"]},{"name":"@babel/plugin-transform-modules-commonjs","version":"7.27.1","files":["/package.json"]},{"name":"@babel/plugin-transform-runtime","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-env","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-react","version":"7.28.5","files":["/package.json"]},{"name":"@babel/preset-typescript","version":"7.28.5","files":["/package.json"]},{"name":"@babel/register","version":"7.28.3","files":["/package.json","/packages/i18n/package.json"]},{"name":"@babel/runtime","version":"7.28.4","files":["/package.json"]},{"name":"@babel/template","version":"7.27.2","files":["/package.json"]},{"name":"@babel/traverse","version":"7.28.5","files":["/package.json"]},{"name":"@babel/types","version":"7.28.5","files":["/package.json"]},{"name":"@commitlint/cli","version":"11.0.0","files":["/package.json"]},{"name":"@commitlint/config-conventional","version":"11.0.0","files":["/package.json"]},{"name":"@elastic/elasticsearch","version":"7.12.0","files":["/packages/api-headless-cms-ddb-es/package.json","/packages/project-utils/package.json"]},{"name":"@emotion/babel-plugin","version":"11.13.5","files":["/packages/app-admin/package.json","/packages/app-admin-ui/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-website-builder/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"@emotion/react","version":"11.10.8","files":["/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"@eslint/eslintrc","version":"3.3.1","files":["/package.json"]},{"name":"@eslint/js","version":"9.39.1","files":["/package.json"]},{"name":"@faker-js/faker","version":"9.9.0","files":["/packages/api-headless-cms-es-tasks/package.json","/packages/api-sync-system/package.json"]},{"name":"@fortawesome/free-solid-svg-icons","version":"6.7.2","files":["/packages/admin-ui/package.json"]},{"name":"@grpc/grpc-js","version":"1.14.0","files":["/package.json"]},{"name":"@lexical/code","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/hashtag","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/headless","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/history","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/html","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/list","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/mark","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/overflow","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/react","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/rich-text","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/selection","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/text","version":"0.35.0","files":["/package.json"]},{"name":"@lexical/utils","version":"0.35.0","files":["/package.json"]},{"name":"@material-design-icons/svg","version":"0.14.15","files":["/packages/icons/package.json"]},{"name":"@octokit/rest","version":"20.1.2","files":["/package.json"]},{"name":"@storybook/addon-a11y","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/addon-docs","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/addon-webpack5-compiler-babel","version":"3.0.6","files":["/packages/admin-ui/package.json"]},{"name":"@storybook/react-webpack5","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"@svgr/webpack","version":"6.5.1","files":["/packages/admin-ui/package.json","/packages/app-file-manager/package.json"]},{"name":"@tailwindcss/postcss","version":"4.1.16","files":["/packages/admin-ui/package.json"]},{"name":"@testing-library/cypress","version":"10.1.0","files":["/cypress-tests/package.json"]},{"name":"@testing-library/react","version":"15.0.7","files":["/packages/form/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/ui/package.json"]},{"name":"@testing-library/user-event","version":"14.6.1","files":["/packages/form/package.json"]},{"name":"@types/accounting","version":"0.4.5","files":["/packages/i18n/package.json"]},{"name":"@types/adm-zip","version":"0.5.7","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/archiver","version":"6.0.3","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/babel__code-frame","version":"7.0.6","files":["/packages/api-headless-cms/package.json"]},{"name":"@types/bytes","version":"3.1.5","files":["/packages/app-admin/package.json"]},{"name":"@types/center-align","version":"1.0.2","files":["/packages/data-migration/package.json"]},{"name":"@types/cli-progress","version":"3.11.6","files":["/scripts/cjsToEsm/package.json"]},{"name":"@types/crypto-js","version":"4.2.2","files":["/packages/api-mailer/package.json"]},{"name":"@types/debounce","version":"1.2.4","files":["/packages/project/package.json"]},{"name":"@types/deep-equal","version":"1.0.4","files":["/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"@types/dot-object","version":"2.1.6","files":["/packages/api-headless-cms-ddb/package.json"]},{"name":"@types/folder-hash","version":"4.0.4","files":["/scripts/buildPackages/package.json"]},{"name":"@types/fs-extra","version":"11.0.4","files":["/package.json"]},{"name":"@types/glob","version":"7.2.0","files":["/packages/i18n/package.json"]},{"name":"@types/graphlib","version":"2.1.12","files":["/packages/app-admin/package.json"]},{"name":"@types/humanize-duration","version":"3.27.4","files":["/packages/project/package.json"]},{"name":"@types/inquirer","version":"8.2.12","files":["/package.json"]},{"name":"@types/invariant","version":"2.2.37","files":["/packages/form/package.json"]},{"name":"@types/is-hotkey","version":"0.1.10","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json","/packages/website-builder-sdk/package.json"]},{"name":"@types/is-number","version":"7.0.5","files":["/packages/db-dynamodb/package.json"]},{"name":"@types/js-yaml","version":"4.0.9","files":["/packages/create-webiny-project/package.json"]},{"name":"@types/jsdom","version":"21.1.7","files":["/packages/lexical-converter/package.json","/packages/project/package.json"]},{"name":"@types/jsonpack","version":"1.1.6","files":["/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json"]},{"name":"@types/jsonwebtoken","version":"9.0.10","files":["/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json"]},{"name":"@types/jwk-to-pem","version":"2.0.3","files":["/packages/api-cognito-authenticator/package.json","/packages/api-security-auth0/package.json"]},{"name":"@types/listr","version":"0.14.9","files":["/packages/cli-core/package.json"]},{"name":"@types/lodash","version":"4.17.20","files":["/packages/api-sync-system/package.json","/packages/app/package.json","/packages/app-cognito-authenticator/package.json","/packages/cli/package.json","/packages/cli-core/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/validation/package.json"]},{"name":"@types/md5","version":"2.3.5","files":["/packages/api-core/package.json"]},{"name":"@types/ncp","version":"2.0.8","files":["/packages/project-aws/package.json"]},{"name":"@types/node","version":"22.18.6","files":["/package.json"]},{"name":"@types/nodemailer","version":"6.4.19","files":["/packages/api-mailer/package.json"]},{"name":"@types/object-hash","version":"3.0.6","files":["/packages/api-file-manager/package.json"]},{"name":"@types/pako","version":"2.0.4","files":["/packages/app-website-builder/package.json"]},{"name":"@types/platform","version":"1.3.6","files":["/packages/app-website-builder/package.json"]},{"name":"@types/pluralize","version":"0.0.33","files":["/packages/api-headless-cms/package.json"]},{"name":"@types/postcss-import","version":"14.0.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"@types/randomcolor","version":"0.5.9","files":["/packages/app-website-builder/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/package.json","/packages/admin-ui/package.json","/packages/app-aco/package.json","/packages/app-audit-logs/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-trash-bin/package.json","/packages/app-workflows/package.json","/packages/theme/package.json","/packages/website-builder-react/package.json"]},{"name":"@types/react-color","version":"2.17.12","files":["/packages/admin-ui/package.json","/packages/lexical-editor-actions/package.json"]},{"name":"@types/react-custom-scrollbars","version":"4.0.13","files":["/packages/admin-ui/package.json"]},{"name":"@types/react-dom","version":"18.2.25","files":["/package.json"]},{"name":"@types/react-helmet","version":"6.1.11","files":["/packages/app-admin-ui/package.json","/packages/app-security-access-management/package.json"]},{"name":"@types/react-images","version":"0.5.3","files":["/packages/app-website-builder/package.json"]},{"name":"@types/react-resizable","version":"3.0.8","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/react-test-renderer","version":"18.3.1","files":["/packages/project/package.json"]},{"name":"@types/react-transition-group","version":"4.4.12","files":["/packages/app-admin/package.json"]},{"name":"@types/react-virtualized","version":"9.22.3","files":["/packages/admin-ui/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/read-json-sync","version":"2.0.3","files":["/packages/project/package.json"]},{"name":"@types/resize-observer-browser","version":"0.1.11","files":["/packages/app-website-builder/package.json"]},{"name":"@types/semver","version":"7.7.1","files":["/packages/data-migration/package.json"]},{"name":"@types/store","version":"2.0.5","files":["/packages/app-admin/package.json","/packages/app-website-builder/package.json"]},{"name":"@types/tinycolor2","version":"1.4.6","files":["/packages/app-admin/package.json"]},{"name":"@types/uniqid","version":"5.3.4","files":["/packages/feature-flags/package.json","/packages/plugins/package.json"]},{"name":"@types/universal-router","version":"8.0.0","files":["/packages/app/package.json"]},{"name":"@types/unzipper","version":"0.10.11","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"@types/validate-npm-package-name","version":"3.0.3","files":["/packages/create-webiny-project/package.json"]},{"name":"@types/warning","version":"3.0.3","files":["/packages/app/package.json"]},{"name":"@types/yargs","version":"17.0.33","files":["/scripts/buildPackages/package.json"]},{"name":"@typescript-eslint/eslint-plugin","version":"8.48.0","files":["/package.json"]},{"name":"@typescript-eslint/parser","version":"8.48.0","files":["/package.json"]},{"name":"@vitest/coverage-v8","version":"3.2.4","files":["/package.json"]},{"name":"@vitest/eslint-plugin","version":"1.4.2","files":["/package.json"]},{"name":"adio","version":"2.0.1","files":["/package.json"]},{"name":"adm-zip","version":"0.5.16","files":["/packages/api-headless-cms-import-export/package.json"]},{"name":"amazon-cognito-identity-js","version":"4.6.3","files":["/cypress-tests/package.json"]},{"name":"apollo-client","version":"2.6.10","files":["/packages/app-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"apollo-graphql","version":"0.9.7","files":["/packages/api-headless-cms/package.json"]},{"name":"apollo-link","version":"1.2.14","files":["/packages/app-aco/package.json","/packages/app-trash-bin/package.json"]},{"name":"aws-sdk-client-mock","version":"4.1.0","files":["/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-scheduler/package.json","/packages/api-sync-system/package.json"]},{"name":"axios","version":"1.12.2","files":["/package.json"]},{"name":"babel-loader","version":"10.0.0","files":["/package.json","/packages/ui/package.json"]},{"name":"babel-plugin-dynamic-import-node","version":"2.3.3","files":["/package.json"]},{"name":"babel-plugin-macros","version":"3.1.0","files":["/package.json"]},{"name":"babel-plugin-module-resolver","version":"5.0.2","files":["/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json"]},{"name":"babel-plugin-named-asset-import","version":"1.0.0-next.fb6e6f70","files":["/packages/app-admin-ui/package.json"]},{"name":"chalk","version":"4.1.2","files":["/package.json","/packages/admin-ui/package.json"]},{"name":"cross-env","version":"5.2.1","files":["/package.json"]},{"name":"cross-spawn","version":"6.0.6","files":["/package.json"]},{"name":"css-loader","version":"7.1.2","files":["/packages/admin-ui/package.json"]},{"name":"cypress","version":"13.17.0","files":["/cypress-tests/package.json"]},{"name":"cypress-image-snapshot","version":"4.0.1","files":["/cypress-tests/package.json"]},{"name":"cypress-mailosaur","version":"2.17.0","files":["/cypress-tests/package.json"]},{"name":"cypress-wait-until","version":"1.7.2","files":["/cypress-tests/package.json"]},{"name":"deepmerge","version":"4.3.1","files":["/package.json"]},{"name":"del","version":"6.1.1","files":["/cypress-tests/package.json"]},{"name":"elastic-ts","version":"0.12.0","files":["/packages/migrations/package.json"]},{"name":"env-ci","version":"2.6.0","files":["/package.json"]},{"name":"eslint","version":"9.39.1","files":["/package.json"]},{"name":"eslint-config-standard","version":"17.1.0","files":["/package.json"]},{"name":"eslint-import-resolver-babel-module","version":"5.3.2","files":["/package.json"]},{"name":"eslint-plugin-import","version":"2.32.0","files":["/package.json"]},{"name":"eslint-plugin-lodash","version":"8.0.0","files":["/package.json"]},{"name":"eslint-plugin-node","version":"11.1.0","files":["/package.json"]},{"name":"eslint-plugin-promise","version":"7.2.1","files":["/package.json"]},{"name":"eslint-plugin-react","version":"7.37.5","files":["/package.json"]},{"name":"eslint-plugin-standard","version":"5.0.0","files":["/package.json"]},{"name":"eslint-plugin-storybook","version":"9.1.16","files":["/packages/admin-ui/package.json"]},{"name":"execa","version":"5.1.1","files":["/package.json","/packages/app-audit-logs/package.json","/packages/app-website-builder/package.json","/packages/common-audit-logs/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"file-loader","version":"6.2.0","files":["/packages/admin-ui/package.json"]},{"name":"folder-hash","version":"4.1.1","files":["/package.json"]},{"name":"fs-extra","version":"11.3.2","files":["/package.json"]},{"name":"get-stream","version":"3.0.0","files":["/package.json"]},{"name":"get-yarn-workspaces","version":"1.0.2","files":["/package.json"]},{"name":"git-cz","version":"1.8.4","files":["/package.json"]},{"name":"github-actions-wac","version":"2.0.0","files":["/package.json"]},{"name":"glob","version":"7.2.3","files":["/package.json"]},{"name":"graphql","version":"16.12.0","files":["/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-file-manager-aco/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-aco/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/testing/package.json"]},{"name":"graphql-request","version":"7.3.5","files":["/cypress-tests/package.json"]},{"name":"husky","version":"4.3.8","files":["/package.json"]},{"name":"identity-obj-proxy","version":"3.0.0","files":["/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"inquirer","version":"12.9.6","files":["/package.json"]},{"name":"jest-dynalite","version":"3.6.1","files":["/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-scheduler/package.json","/packages/api-sync-system/package.json","/packages/data-migration/package.json","/packages/db-dynamodb/package.json","/packages/migrations/package.json","/packages/project-utils/package.json"]},{"name":"jest-extended","version":"6.0.0","files":["/package.json"]},{"name":"jsdom","version":"25.0.1","files":["/packages/lexical-converter/package.json"]},{"name":"jsonpack","version":"1.1.5","files":["/packages/api-file-manager-ddb/package.json"]},{"name":"lerna","version":"8.1.2","files":["/package.json"]},{"name":"lexical","version":"0.35.0","files":["/package.json"]},{"name":"lint-staged","version":"16.1.6","files":["/package.json"]},{"name":"listr","version":"0.14.3","files":["/package.json"]},{"name":"listr2","version":"5.0.8","files":["/packages/project-utils/package.json"]},{"name":"load-json-file","version":"6.2.0","files":["/package.json","/packages/project-utils/package.json"]},{"name":"lodash","version":"4.17.21","files":["/package.json","/cypress-tests/package.json"]},{"name":"longest","version":"2.0.1","files":["/package.json"]},{"name":"md5","version":"2.3.0","files":["/packages/api-security-cognito/package.json"]},{"name":"minimatch","version":"5.1.6","files":["/package.json"]},{"name":"mobx","version":"6.15.0","files":["/packages/form/package.json"]},{"name":"mobx-react-lite","version":"3.4.3","files":["/packages/form/package.json"]},{"name":"nanoid","version":"3.3.11","files":["/package.json","/cypress-tests/package.json"]},{"name":"ncp","version":"2.0.0","files":["/packages/ui/package.json"]},{"name":"next","version":"15.5.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"pino","version":"9.13.1","files":["/packages/logger/package.json","/packages/project-utils/package.json"]},{"name":"pino-pretty","version":"9.4.1","files":["/packages/project-utils/package.json"]},{"name":"postcss-loader","version":"8.2.0","files":["/packages/admin-ui/package.json"]},{"name":"prettier","version":"3.6.2","files":["/package.json","/packages/admin-ui/package.json","/packages/api-aco/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-website-builder/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json"]},{"name":"raw-loader","version":"4.0.2","files":["/packages/ui/package.json"]},{"name":"react","version":"18.2.0","files":["/package.json","/packages/lexical-nodes/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/package.json"]},{"name":"react-router","version":"7.9.1","files":["/packages/admin-ui/package.json"]},{"name":"react-router-dom","version":"7.9.1","files":["/packages/admin-ui/package.json"]},{"name":"rimraf","version":"6.0.1","files":["/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-background-tasks-ddb/package.json","/packages/api-background-tasks-os/package.json","/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-scheduler/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json","/packages/api-security-okta/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/aws-sdk/package.json","/packages/cli-core/package.json","/packages/common-audit-logs/package.json","/packages/data-migration/package.json","/packages/db/package.json","/packages/db-dynamodb/package.json","/packages/error/package.json","/packages/feature-flags/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-client/package.json","/packages/handler-db/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/logger/package.json","/packages/plugins/package.json","/packages/project/package.json","/packages/pubsub/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/tasks/package.json","/packages/testing/package.json","/packages/theme/package.json","/packages/ui/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/wcp/package.json","/packages/webiny/package.json","/scripts/cli/package.json"]},{"name":"sass","version":"1.93.0","files":["/packages/admin-ui/package.json"]},{"name":"semver","version":"7.7.2","files":["/package.json"]},{"name":"storybook","version":"9.1.8","files":["/packages/admin-ui/package.json"]},{"name":"ts-expect","version":"1.3.0","files":["/package.json"]},{"name":"tsx","version":"4.20.5","files":["/package.json"]},{"name":"ttypescript","version":"1.5.15","files":["/packages/api-file-manager-aco/package.json"]},{"name":"type-fest","version":"5.2.0","files":["/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-record-locking/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/project/package.json","/packages/tasks/package.json","/scripts/prepublishOnly/package.json"]},{"name":"typescript","version":"5.9.3","files":["/package.json","/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-background-tasks-ddb/package.json","/packages/api-background-tasks-os/package.json","/packages/api-cognito-authenticator/package.json","/packages/api-core/package.json","/packages/api-core-ddb/package.json","/packages/api-dynamodb-to-elasticsearch/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager/package.json","/packages/api-file-manager-aco/package.json","/packages/api-file-manager-ddb/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-aco/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-headless-cms-es-tasks/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-headless-cms-tasks-ddb-es/package.json","/packages/api-headless-cms-workflows/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-scheduler/package.json","/packages/api-security-auth0/package.json","/packages/api-security-cognito/package.json","/packages/api-security-okta/package.json","/packages/api-sync-system/package.json","/packages/api-website-builder/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-admin-auth0/package.json","/packages/app-admin-cognito/package.json","/packages/app-admin-okta/package.json","/packages/app-admin-ui/package.json","/packages/app-admin-users-cognito/package.json","/packages/app-audit-logs/package.json","/packages/app-cognito-authenticator/package.json","/packages/app-file-manager/package.json","/packages/app-file-manager-s3/package.json","/packages/app-graphql-playground/package.json","/packages/app-headless-cms/package.json","/packages/app-headless-cms-common/package.json","/packages/app-headless-cms-scheduler/package.json","/packages/app-headless-cms-workflows/package.json","/packages/app-mailer/package.json","/packages/app-record-locking/package.json","/packages/app-security/package.json","/packages/app-security-access-management/package.json","/packages/app-serverless-cms/package.json","/packages/app-trash-bin/package.json","/packages/app-utils/package.json","/packages/app-website-builder/package.json","/packages/app-websockets/package.json","/packages/app-workflows/package.json","/packages/aws-sdk/package.json","/packages/cli-core/package.json","/packages/common-audit-logs/package.json","/packages/data-migration/package.json","/packages/db/package.json","/packages/db-dynamodb/package.json","/packages/error/package.json","/packages/feature/package.json","/packages/feature-flags/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-client/package.json","/packages/handler-db/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/ioc/package.json","/packages/logger/package.json","/packages/migrations/package.json","/packages/plugins/package.json","/packages/project/package.json","/packages/project-aws/package.json","/packages/pubsub/package.json","/packages/pulumi/package.json","/packages/pulumi-sdk/package.json","/packages/react-composition/package.json","/packages/shared-aco/package.json","/packages/tasks/package.json","/packages/testing/package.json","/packages/theme/package.json","/packages/ui/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/wcp/package.json","/packages/webiny/package.json","/packages/website-builder-nextjs/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json","/cypress-tests/package.json","/scripts/cli/package.json"]},{"name":"uniqid","version":"5.4.0","files":["/cypress-tests/package.json"]},{"name":"validator","version":"13.15.23","files":["/package.json"]},{"name":"verdaccio","version":"6.2.1","files":["/package.json"]},{"name":"vite-tsconfig-paths","version":"5.1.4","files":["/package.json"]},{"name":"vitest","version":"3.2.4","files":["/package.json","/packages/admin-ui/package.json","/packages/api/package.json","/packages/api-aco/package.json","/packages/api-audit-logs/package.json","/packages/api-core/package.json","/packages/api-elasticsearch/package.json","/packages/api-elasticsearch-tasks/package.json","/packages/api-file-manager-aco/package.json","/packages/api-file-manager-s3/package.json","/packages/api-headless-cms/package.json","/packages/api-headless-cms-bulk-actions/package.json","/packages/api-headless-cms-ddb/package.json","/packages/api-headless-cms-ddb-es/package.json","/packages/api-headless-cms-es-tasks/package.json","/packages/api-headless-cms-import-export/package.json","/packages/api-headless-cms-scheduler/package.json","/packages/api-headless-cms-tasks/package.json","/packages/api-headless-cms-workflows/package.json","/packages/api-log/package.json","/packages/api-mailer/package.json","/packages/api-record-locking/package.json","/packages/api-scheduler/package.json","/packages/api-security-cognito/package.json","/packages/api-sync-system/package.json","/packages/api-websockets/package.json","/packages/api-workflows/package.json","/packages/app/package.json","/packages/app-aco/package.json","/packages/app-admin/package.json","/packages/app-file-manager/package.json","/packages/app-headless-cms/package.json","/packages/app-website-builder/package.json","/packages/data-migration/package.json","/packages/db-dynamodb/package.json","/packages/form/package.json","/packages/handler/package.json","/packages/handler-aws/package.json","/packages/handler-graphql/package.json","/packages/i18n/package.json","/packages/ioc/package.json","/packages/lexical-converter/package.json","/packages/migrations/package.json","/packages/plugins/package.json","/packages/project-utils/package.json","/packages/pubsub/package.json","/packages/react-composition/package.json","/packages/react-properties/package.json","/packages/react-rich-text-lexical-renderer/package.json","/packages/tasks/package.json","/packages/utils/package.json","/packages/validation/package.json","/packages/website-builder-nextjs/package.json","/packages/website-builder-react/package.json","/packages/website-builder-sdk/package.json"]},{"name":"webpack","version":"5.101.3","files":["/packages/website-builder-nextjs/package.json"]},{"name":"write-json-file","version":"4.3.0","files":["/package.json","/packages/api-headless-cms/package.json"]},{"name":"yargs","version":"17.7.2","files":["/package.json","/packages/project-utils/package.json"]},{"name":"zod","version":"3.25.76","files":["/packages/ioc/package.json"]}],"peerDependencies":[{"name":"minimatch","version":"5.1.6","files":["/packages/ui/package.json"]},{"name":"react","version":"18.2.0","files":["/packages/app-audit-logs/package.json","/packages/form/package.json","/packages/i18n/package.json","/packages/i18n-react/package.json","/packages/theme/package.json","/packages/ui/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/packages/ui/package.json"]}],"resolutions":[{"name":"@emotion/react","version":"11.10.8","files":["/package.json"]},{"name":"@octokit/rest","version":"20.1.2","files":["/package.json"]},{"name":"@types/react","version":"18.2.79","files":["/package.json"]},{"name":"@types/react-dom","version":"18.2.25","files":["/package.json"]},{"name":"react","version":"18.2.0","files":["/package.json"]},{"name":"react-dom","version":"18.2.0","files":["/package.json"]},{"name":"semver","version":"7.7.2","files":["/package.json"]},{"name":"systeminformation","version":"5.23.18","files":["/package.json"]},{"name":"validator","version":"13.15.23","files":["/package.json"]}],"references":[{"name":"@types/hoist-non-react-statics","versions":[{"version":"3.3.7","files":[{"file":"/package.json","types":["dependencies"]}]}]},{"name":"@babel/cli","versions":[{"version":"7.28.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/code-frame","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@babel/compat-data","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/core","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/helper-define-polyfill-provider","versions":[{"version":"0.6.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/helper-environment-visitor","versions":[{"version":"7.24.7","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/parser","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-class-properties","versions":[{"version":"7.18.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-object-rest-spread","versions":[{"version":"7.20.7","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-proposal-throw-expressions","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-syntax-object-rest-spread","versions":[{"version":"7.8.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-transform-modules-commonjs","versions":[{"version":"7.27.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/plugin-transform-runtime","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/preset-env","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/preset-react","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/preset-typescript","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/register","versions":[{"version":"7.28.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"@babel/runtime","versions":[{"version":"7.28.4","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@babel/template","versions":[{"version":"7.27.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/traverse","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@babel/types","versions":[{"version":"7.28.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@commitlint/cli","versions":[{"version":"11.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@commitlint/config-conventional","versions":[{"version":"11.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@eslint/eslintrc","versions":[{"version":"3.3.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@eslint/js","versions":[{"version":"9.39.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@grpc/grpc-js","versions":[{"version":"1.14.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@lexical/code","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/hashtag","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/headless","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"@lexical/history","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/html","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"@lexical/list","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/mark","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/overflow","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/react","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/rich-text","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/selection","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@lexical/text","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]}]}]},{"name":"@lexical/utils","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"@octokit/rest","versions":[{"version":"20.1.2","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"@types/fs-extra","versions":[{"version":"11.0.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/inquirer","versions":[{"version":"8.2.12","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/node","versions":[{"version":"22.18.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@types/react","versions":[{"version":"18.2.79","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-dom","versions":[{"version":"18.2.25","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"@typescript-eslint/eslint-plugin","versions":[{"version":"8.48.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@typescript-eslint/parser","versions":[{"version":"8.48.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@vitest/coverage-v8","versions":[{"version":"3.2.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"@vitest/eslint-plugin","versions":[{"version":"1.4.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"adio","versions":[{"version":"2.0.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"axios","versions":[{"version":"1.12.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-loader","versions":[{"version":"10.0.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-dynamic-import-node","versions":[{"version":"2.3.3","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-macros","versions":[{"version":"3.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-module-resolver","versions":[{"version":"5.0.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]}]}]},{"name":"chalk","versions":[{"version":"4.1.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/aws-layers/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"cross-env","versions":[{"version":"5.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"cross-spawn","versions":[{"version":"6.0.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"deepmerge","versions":[{"version":"4.3.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"env-ci","versions":[{"version":"2.6.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint","versions":[{"version":"9.39.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"eslint-config-standard","versions":[{"version":"17.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-import-resolver-babel-module","versions":[{"version":"5.3.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-import","versions":[{"version":"2.32.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-lodash","versions":[{"version":"8.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-node","versions":[{"version":"11.1.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-promise","versions":[{"version":"7.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-react","versions":[{"version":"7.37.5","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"eslint-plugin-standard","versions":[{"version":"5.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"execa","versions":[{"version":"5.1.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/cli/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"folder-hash","versions":[{"version":"4.1.1","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"fs-extra","versions":[{"version":"11.3.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"get-stream","versions":[{"version":"3.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"get-yarn-workspaces","versions":[{"version":"1.0.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"git-cz","versions":[{"version":"1.8.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"github-actions-wac","versions":[{"version":"2.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"glob","versions":[{"version":"7.2.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"graphql","versions":[{"version":"16.12.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies","devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]}]}]},{"name":"husky","versions":[{"version":"4.3.8","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"inquirer","versions":[{"version":"12.9.6","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"jest-extended","versions":[{"version":"6.0.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"lerna","versions":[{"version":"8.1.2","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"lexical","versions":[{"version":"0.35.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"lint-staged","versions":[{"version":"16.1.6","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"listr","versions":[{"version":"0.14.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"load-json-file","versions":[{"version":"6.2.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/telemetry/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"lodash","versions":[{"version":"4.17.21","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-aco/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["dependencies"]},{"file":"/packages/api-mailer/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/api-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]},{"file":"/packages/i18n-react/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]},{"file":"/packages/validation/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"longest","versions":[{"version":"2.0.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"minimatch","versions":[{"version":"5.1.6","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]}]}]},{"name":"nanoid","versions":[{"version":"3.3.11","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"prettier","versions":[{"version":"3.6.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]}]}]},{"name":"react","versions":[{"version":"18.2.0","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["peerDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-websockets/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["peerDependencies"]},{"file":"/packages/i18n/package.json","types":["peerDependencies"]},{"file":"/packages/i18n-react/package.json","types":["peerDependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-nodes/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/react-properties/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["dependencies"]},{"file":"/packages/theme/package.json","types":["peerDependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"react-dom","versions":[{"version":"18.2.0","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-websockets/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/react-composition/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["peerDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"semver","versions":[{"version":"7.7.2","files":[{"file":"/package.json","types":["devDependencies","resolutions"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/packages/system-requirements/package.json","types":["dependencies"]}]}]},{"name":"ts-expect","versions":[{"version":"1.3.0","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"tsx","versions":[{"version":"4.20.5","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"type-fest","versions":[{"version":"5.2.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["dependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/db/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"typescript","versions":[{"version":"5.9.3","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-os/package.json","types":["devDependencies"]},{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-dynamodb-to-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-security-okta/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-utils/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/app-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/aws-sdk/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/error/package.json","types":["devDependencies"]},{"file":"/packages/feature/package.json","types":["devDependencies"]},{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-client/package.json","types":["devDependencies"]},{"file":"/packages/handler-db/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/i18n-react/package.json","types":["devDependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/project-aws/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/shared-aco/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/wcp/package.json","types":["devDependencies"]},{"file":"/packages/webiny/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["devDependencies"]}]}]},{"name":"validator","versions":[{"version":"13.15.23","files":[{"file":"/package.json","types":["devDependencies","resolutions"]}]}]},{"name":"verdaccio","versions":[{"version":"6.2.1","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"vite-tsconfig-paths","versions":[{"version":"5.1.4","files":[{"file":"/package.json","types":["devDependencies"]}]}]},{"name":"vitest","versions":[{"version":"3.2.4","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-aco/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-bulk-actions/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/api-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"write-json-file","versions":[{"version":"4.3.0","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]}]}]},{"name":"yargs","versions":[{"version":"17.7.2","files":[{"file":"/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/i18n/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"systeminformation","versions":[{"version":"5.23.18","files":[{"file":"/package.json","types":["resolutions"]}]}]},{"name":"@emotion/react","versions":[{"version":"11.10.8","files":[{"file":"/package.json","types":["resolutions"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/fontawesome-svg-core","versions":[{"version":"1.3.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/react-fontawesome","versions":[{"version":"0.1.19","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]}]}]},{"name":"@minoru/react-dnd-treeview","versions":[{"version":"3.5.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"@monaco-editor/react","versions":[{"version":"4.7.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"@tanstack/react-table","versions":[{"version":"8.21.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"bytes","versions":[{"version":"3.1.2","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"class-variance-authority","versions":[{"version":"0.7.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"clsx","versions":[{"version":"2.1.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"cmdk","versions":[{"version":"1.1.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"mobx","versions":[{"version":"6.15.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-utils/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"monaco-editor","versions":[{"version":"0.53.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"radix-ui","versions":[{"version":"1.4.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"react-color","versions":[{"version":"2.19.3","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]}]}]},{"name":"react-custom-scrollbars","versions":[{"version":"4.2.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-dnd","versions":[{"version":"16.0.1","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"react-virtualized","versions":[{"version":"9.22.6","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"sonner","versions":[{"version":"2.0.7","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tailwind-merge","versions":[{"version":"2.6.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tailwindcss","versions":[{"version":"4.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"timeago-react","versions":[{"version":"3.0.7","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"tw-animate-css","versions":[{"version":"1.4.0","files":[{"file":"/packages/admin-ui/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-solid-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@storybook/addon-a11y","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/addon-docs","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/addon-webpack5-compiler-babel","versions":[{"version":"3.0.6","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@storybook/react-webpack5","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@svgr/webpack","versions":[{"version":"6.5.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]}]}]},{"name":"@tailwindcss/postcss","versions":[{"version":"4.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@types/react-color","versions":[{"version":"2.17.12","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-custom-scrollbars","versions":[{"version":"4.0.13","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-virtualized","versions":[{"version":"9.22.3","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"css-loader","versions":[{"version":"7.1.2","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"eslint-plugin-storybook","versions":[{"version":"9.1.16","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"file-loader","versions":[{"version":"6.2.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"postcss-loader","versions":[{"version":"8.2.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"react-router","versions":[{"version":"7.9.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"react-router-dom","versions":[{"version":"7.9.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"rimraf","versions":[{"version":"6.0.1","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/api/package.json","types":["devDependencies"]},{"file":"/packages/api-aco/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-background-tasks-os/package.json","types":["devDependencies"]},{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch/package.json","types":["devDependencies"]},{"file":"/packages/api-elasticsearch-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/api-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]},{"file":"/packages/api-security-okta/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/api-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/api-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["devDependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager/package.json","types":["devDependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["devDependencies"]},{"file":"/packages/app-mailer/package.json","types":["devDependencies"]},{"file":"/packages/app-record-locking/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/app-websockets/package.json","types":["devDependencies"]},{"file":"/packages/app-workflows/package.json","types":["devDependencies"]},{"file":"/packages/aws-sdk/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/common-audit-logs/package.json","types":["devDependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/error/package.json","types":["devDependencies"]},{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/handler/package.json","types":["devDependencies"]},{"file":"/packages/handler-aws/package.json","types":["devDependencies"]},{"file":"/packages/handler-client/package.json","types":["devDependencies"]},{"file":"/packages/handler-db/package.json","types":["devDependencies"]},{"file":"/packages/handler-graphql/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/i18n-react/package.json","types":["devDependencies"]},{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/pubsub/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/tasks/package.json","types":["devDependencies"]},{"file":"/packages/testing/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]},{"file":"/packages/wcp/package.json","types":["devDependencies"]},{"file":"/packages/webiny/package.json","types":["devDependencies"]},{"file":"/scripts/cli/package.json","types":["devDependencies"]}]}]},{"name":"sass","versions":[{"version":"1.93.0","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"storybook","versions":[{"version":"9.1.8","files":[{"file":"/packages/admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"zod","versions":[{"version":"3.25.76","files":[{"file":"/packages/api-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-file-manager/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-tasks/package.json","types":["dependencies"]},{"file":"/packages/api-log/package.json","types":["dependencies"]},{"file":"/packages/api-mailer/package.json","types":["dependencies"]},{"file":"/packages/api-scheduler/package.json","types":["dependencies"]},{"file":"/packages/api-sync-system/package.json","types":["dependencies"]},{"file":"/packages/api-websockets/package.json","types":["dependencies"]},{"file":"/packages/api-workflows/package.json","types":["dependencies"]},{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]},{"file":"/packages/ioc/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"jsonwebtoken","versions":[{"version":"9.0.2","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["dependencies"]},{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["dependencies"]},{"file":"/packages/api-security-okta/package.json","types":["dependencies"]}]}]},{"name":"@types/jsonwebtoken","versions":[{"version":"9.0.10","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]}]}]},{"name":"@types/jwk-to-pem","versions":[{"version":"2.0.3","files":[{"file":"/packages/api-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/api-security-auth0/package.json","types":["devDependencies"]}]}]},{"name":"dataloader","versions":[{"version":"2.2.3","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["dependencies"]}]}]},{"name":"deep-equal","versions":[{"version":"2.2.3","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/tasks/package.json","types":["dependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"jose","versions":[{"version":"5.10.0","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]}]}]},{"name":"md5","versions":[{"version":"2.3.0","files":[{"file":"/packages/api-core/package.json","types":["dependencies"]},{"file":"/packages/api-security-cognito/package.json","types":["devDependencies"]}]}]},{"name":"@types/md5","versions":[{"version":"2.3.5","files":[{"file":"/packages/api-core/package.json","types":["devDependencies"]}]}]},{"name":"jest-dynalite","versions":[{"version":"3.6.1","files":[{"file":"/packages/api-core/package.json","types":["devDependencies"]},{"file":"/packages/api-core-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/api-log/package.json","types":["devDependencies"]},{"file":"/packages/api-mailer/package.json","types":["devDependencies"]},{"file":"/packages/api-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["devDependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]}]}]},{"name":"p-retry","versions":[{"version":"7.0.0","files":[{"file":"/packages/api-dynamodb-to-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"@elastic/elasticsearch","versions":[{"version":"7.12.0","files":[{"file":"/packages/api-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]},{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]}]}]},{"name":"elastic-ts","versions":[{"version":"0.12.0","files":[{"file":"/packages/api-elasticsearch/package.json","types":["dependencies"]},{"file":"/packages/migrations/package.json","types":["devDependencies"]}]}]},{"name":"cache-control-parser","versions":[{"version":"2.0.6","files":[{"file":"/packages/api-file-manager/package.json","types":["dependencies"]}]}]},{"name":"object-hash","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager/package.json","types":["dependencies"]},{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"@types/object-hash","versions":[{"version":"3.0.6","files":[{"file":"/packages/api-file-manager/package.json","types":["devDependencies"]}]}]},{"name":"ttypescript","versions":[{"version":"1.5.15","files":[{"file":"/packages/api-file-manager-aco/package.json","types":["devDependencies"]}]}]},{"name":"jsonpack","versions":[{"version":"1.1.5","files":[{"file":"/packages/api-file-manager-ddb/package.json","types":["devDependencies"]},{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"mime","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"p-map","versions":[{"version":"7.0.3","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"p-reduce","versions":[{"version":"3.0.0","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"sanitize-filename","versions":[{"version":"1.6.3","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"sharp","versions":[{"version":"0.34.4","files":[{"file":"/packages/api-file-manager-s3/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/merge","versions":[{"version":"9.1.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/schema","versions":[{"version":"10.0.30","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"dot-prop","versions":[{"version":"6.0.1","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"graphql-tag","versions":[{"version":"2.12.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager-s3/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"jsdom","versions":[{"version":"25.0.1","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]}]}]},{"name":"pluralize","versions":[{"version":"8.0.0","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"slugify","versions":[{"version":"1.6.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"@types/babel__code-frame","versions":[{"version":"7.0.6","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"@types/pluralize","versions":[{"version":"0.0.33","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"apollo-graphql","versions":[{"version":"0.9.7","files":[{"file":"/packages/api-headless-cms/package.json","types":["devDependencies"]}]}]},{"name":"dot-object","versions":[{"version":"2.1.5","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["dependencies"]}]}]},{"name":"@types/dot-object","versions":[{"version":"2.1.6","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]}]}]},{"name":"@types/jsonpack","versions":[{"version":"1.1.6","files":[{"file":"/packages/api-headless-cms-ddb/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-ddb-es/package.json","types":["devDependencies"]}]}]},{"name":"@faker-js/faker","versions":[{"version":"9.9.0","files":[{"file":"/packages/api-headless-cms-es-tasks/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]}]}]},{"name":"@smithy/node-http-handler","versions":[{"version":"2.5.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"archiver","versions":[{"version":"7.0.1","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"uniqid","versions":[{"version":"5.4.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]},{"file":"/packages/plugins/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"unzipper","versions":[{"version":"0.12.3","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["dependencies"]}]}]},{"name":"@types/adm-zip","versions":[{"version":"0.5.7","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"@types/archiver","versions":[{"version":"6.0.3","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"@types/unzipper","versions":[{"version":"0.10.11","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"adm-zip","versions":[{"version":"0.5.16","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]}]}]},{"name":"aws-sdk-client-mock","versions":[{"version":"4.1.0","files":[{"file":"/packages/api-headless-cms-import-export/package.json","types":["devDependencies"]},{"file":"/packages/api-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]}]}]},{"name":"crypto-js","versions":[{"version":"4.2.0","files":[{"file":"/packages/api-mailer/package.json","types":["dependencies"]}]}]},{"name":"nodemailer","versions":[{"version":"7.0.10","files":[{"file":"/packages/api-mailer/package.json","types":["dependencies"]}]}]},{"name":"@types/crypto-js","versions":[{"version":"4.2.2","files":[{"file":"/packages/api-mailer/package.json","types":["devDependencies"]}]}]},{"name":"@types/nodemailer","versions":[{"version":"6.4.19","files":[{"file":"/packages/api-mailer/package.json","types":["devDependencies"]}]}]},{"name":"@types/lodash","versions":[{"version":"4.17.20","files":[{"file":"/packages/api-sync-system/package.json","types":["devDependencies"]},{"file":"/packages/app/package.json","types":["devDependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["devDependencies"]},{"file":"/packages/cli/package.json","types":["devDependencies"]},{"file":"/packages/cli-core/package.json","types":["devDependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/i18n/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]},{"file":"/packages/project-aws/package.json","types":["devDependencies"]},{"file":"/packages/pulumi/package.json","types":["devDependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["devDependencies"]},{"file":"/packages/validation/package.json","types":["devDependencies"]}]}]},{"name":"@apollo/react-hooks","versions":[{"version":"3.1.5","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"@emotion/styled","versions":[{"version":"11.10.6","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]}]}]},{"name":"apollo-cache","versions":[{"version":"1.3.5","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"apollo-cache-inmemory","versions":[{"version":"1.6.6","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-client","versions":[{"version":"2.6.10","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"apollo-link","versions":[{"version":"1.2.14","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-aco/package.json","types":["devDependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-record-locking/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-context","versions":[{"version":"1.0.20","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-error","versions":[{"version":"1.1.13","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-http-common","versions":[{"version":"0.2.16","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"apollo-utilities","versions":[{"version":"1.3.4","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"boolean","versions":[{"version":"3.2.0","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"history","versions":[{"version":"5.3.0","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"invariant","versions":[{"version":"2.2.4","files":[{"file":"/packages/app/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"ts-invariant","versions":[{"version":"0.10.3","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"universal-router","versions":[{"version":"9.2.1","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"warning","versions":[{"version":"4.0.3","files":[{"file":"/packages/app/package.json","types":["dependencies"]}]}]},{"name":"@types/universal-router","versions":[{"version":"8.0.0","files":[{"file":"/packages/app/package.json","types":["devDependencies"]}]}]},{"name":"@types/warning","versions":[{"version":"3.0.3","files":[{"file":"/packages/app/package.json","types":["devDependencies"]}]}]},{"name":"dot-prop-immutable","versions":[{"version":"2.1.1","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]}]}]},{"name":"mobx-react-lite","versions":[{"version":"3.4.3","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-trash-bin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]},{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-react/package.json","types":["dependencies"]}]}]},{"name":"pako","versions":[{"version":"2.1.0","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]}]}]},{"name":"react-hotkeyz","versions":[{"version":"1.0.4","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"store","versions":[{"version":"2.0.12","files":[{"file":"/packages/app-aco/package.json","types":["dependencies"]},{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@apollo/react-components","versions":[{"version":"3.1.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]}]}]},{"name":"@iconify/json","versions":[{"version":"2.2.386","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@types/mime","versions":[{"version":"2.0.3","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"case","versions":[{"version":"1.6.3","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"classnames","versions":[{"version":"2.5.1","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["dependencies"]}]}]},{"name":"emotion","versions":[{"version":"10.0.27","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor/package.json","types":["dependencies"]},{"file":"/packages/lexical-editor-actions/package.json","types":["dependencies"]},{"file":"/packages/lexical-theme/package.json","types":["dependencies"]}]}]},{"name":"graphlib","versions":[{"version":"2.1.8","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"is-hotkey","versions":[{"version":"0.2.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"prop-types","versions":[{"version":"15.8.1","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/app-mailer/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"react-draggable","versions":[{"version":"4.5.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-resizable","versions":[{"version":"3.0.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-resizable-panels","versions":[{"version":"2.1.9","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"react-transition-group","versions":[{"version":"4.4.5","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"reset-css","versions":[{"version":"5.0.2","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"tinycolor2","versions":[{"version":"1.6.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"ts-morph","versions":[{"version":"24.0.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]},{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"unicode-emoji-json","versions":[{"version":"0.8.0","files":[{"file":"/packages/app-admin/package.json","types":["dependencies"]}]}]},{"name":"@emotion/babel-plugin","versions":[{"version":"11.13.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["devDependencies"]},{"file":"/packages/app-headless-cms-scheduler/package.json","types":["devDependencies"]},{"file":"/packages/app-security/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]},{"file":"/packages/app-serverless-cms/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/theme/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@types/bytes","versions":[{"version":"3.1.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/graphlib","versions":[{"version":"2.1.12","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/is-hotkey","versions":[{"version":"0.1.10","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-resizable","versions":[{"version":"3.0.8","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-transition-group","versions":[{"version":"4.4.12","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@types/store","versions":[{"version":"2.0.5","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]},{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/tinycolor2","versions":[{"version":"1.4.6","files":[{"file":"/packages/app-admin/package.json","types":["devDependencies"]}]}]},{"name":"@auth0/auth0-react","versions":[{"version":"2.5.0","files":[{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]}]}]},{"name":"react-helmet","versions":[{"version":"6.1.0","files":[{"file":"/packages/app-admin-auth0/package.json","types":["dependencies"]},{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]},{"file":"/packages/app-admin-ui/package.json","types":["dependencies"]},{"file":"/packages/app-admin-users-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-workflows/package.json","types":["dependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["dependencies"]},{"file":"/packages/app-workflows/package.json","types":["dependencies"]}]}]},{"name":"@aws-amplify/auth","versions":[{"version":"5.6.15","files":[{"file":"/packages/app-admin-cognito/package.json","types":["dependencies"]},{"file":"/packages/app-cognito-authenticator/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-auth-js","versions":[{"version":"5.11.0","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-react","versions":[{"version":"6.10.0","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@okta/okta-signin-widget","versions":[{"version":"5.16.1","files":[{"file":"/packages/app-admin-okta/package.json","types":["dependencies"]}]}]},{"name":"@types/react-helmet","versions":[{"version":"6.1.11","files":[{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]},{"file":"/packages/app-security-access-management/package.json","types":["devDependencies"]}]}]},{"name":"babel-plugin-named-asset-import","versions":[{"version":"1.0.0-next.fb6e6f70","files":[{"file":"/packages/app-admin-ui/package.json","types":["devDependencies"]}]}]},{"name":"date-fns","versions":[{"version":"2.30.0","files":[{"file":"/packages/app-audit-logs/package.json","types":["dependencies"]},{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"@apollo/react-common","versions":[{"version":"3.1.4","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"cropperjs","versions":[{"version":"1.6.2","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"dataurl-to-blob","versions":[{"version":"0.0.1","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"dayjs","versions":[{"version":"1.11.18","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"load-script","versions":[{"version":"1.0.0","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-graphql-playground/package.json","types":["dependencies"]}]}]},{"name":"react-butterfiles","versions":[{"version":"1.3.3","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-lazy-load","versions":[{"version":"3.1.14","files":[{"file":"/packages/app-file-manager/package.json","types":["dependencies"]}]}]},{"name":"@emotion/css","versions":[{"version":"11.10.6","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/fontawesome-common-types","versions":[{"version":"0.3.0","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-brands-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@fortawesome/free-regular-svg-icons","versions":[{"version":"6.7.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"dnd-core","versions":[{"version":"16.0.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-headless-cms-common/package.json","types":["dependencies"]}]}]},{"name":"raw.macro","versions":[{"version":"0.4.2","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"react-dnd-html5-backend","versions":[{"version":"16.0.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]},{"file":"/packages/app-website-builder/package.json","types":["dependencies"]}]}]},{"name":"use-deep-compare-effect","versions":[{"version":"1.8.1","files":[{"file":"/packages/app-headless-cms/package.json","types":["dependencies"]}]}]},{"name":"@material-design-icons/svg","versions":[{"version":"0.14.15","files":[{"file":"/packages/app-headless-cms-scheduler/package.json","types":["dependencies"]},{"file":"/packages/icons/package.json","types":["devDependencies"]}]}]},{"name":"crypto-hash","versions":[{"version":"3.1.0","files":[{"file":"/packages/app-record-locking/package.json","types":["dependencies"]}]}]},{"name":"apollo-link-batch-http","versions":[{"version":"1.2.14","files":[{"file":"/packages/app-serverless-cms/package.json","types":["dependencies"]}]}]},{"name":"matcher","versions":[{"version":"5.0.0","files":[{"file":"/packages/app-website-builder/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@types/deep-equal","versions":[{"version":"1.0.4","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["devDependencies"]}]}]},{"name":"@types/pako","versions":[{"version":"2.0.4","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/platform","versions":[{"version":"1.3.6","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/randomcolor","versions":[{"version":"0.5.9","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-images","versions":[{"version":"0.5.3","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/resize-observer-browser","versions":[{"version":"0.1.11","files":[{"file":"/packages/app-website-builder/package.json","types":["devDependencies"]}]}]},{"name":"@types/aws-lambda","versions":[{"version":"8.10.152","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]},{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"cheerio","versions":[{"version":"1.1.2","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]},{"file":"/packages/lexical-converter/package.json","types":["dependencies"]}]}]},{"name":"srcset","versions":[{"version":"4.0.0","files":[{"file":"/packages/aws-helpers/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-apigatewaymanagementapi","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudfront","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudwatch-events","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cloudwatch-logs","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-cognito-identity-provider","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-dynamodb-streams","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-eventbridge","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-iam","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-iot","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-lambda","versions":[{"version":"3.942.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-s3","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-scheduler","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sfn","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sqs","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/client-sts","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/credential-providers","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/lib-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/lib-storage","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/s3-presigned-post","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/s3-request-presigner","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@aws-sdk/util-dynamodb","versions":[{"version":"3.940.0","files":[{"file":"/packages/aws-sdk/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/core","versions":[{"version":"1.6.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-react","versions":[{"version":"1.4.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-sass","versions":[{"version":"1.4.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-svgr","versions":[{"version":"1.2.2","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rsbuild/plugin-type-check","versions":[{"version":"1.3.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@rspack/core","versions":[{"version":"1.5.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@swc/plugin-emotion","versions":[{"version":"11.1.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"@types/webpack-env","versions":[{"version":"1.18.8","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"chokidar","versions":[{"version":"4.0.3","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"fast-glob","versions":[{"version":"3.3.3","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"find-up","versions":[{"version":"5.0.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/prepublishOnly/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"process","versions":[{"version":"0.11.10","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"raw-loader","versions":[{"version":"4.0.2","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"react-refresh","versions":[{"version":"0.11.0","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"read-json-sync","versions":[{"version":"2.0.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]},{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"sass-loader","versions":[{"version":"16.0.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"style-loader","versions":[{"version":"3.3.4","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"url-loader","versions":[{"version":"4.1.1","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"utf-8-validate","versions":[{"version":"6.0.5","files":[{"file":"/packages/build-tools/package.json","types":["dependencies"]}]}]},{"name":"humanize-duration","versions":[{"version":"3.33.1","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"open","versions":[{"version":"10.2.0","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]}]}]},{"name":"ora","versions":[{"version":"4.1.1","files":[{"file":"/packages/cli-core/package.json","types":["dependencies"]},{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"@types/listr","versions":[{"version":"0.14.9","files":[{"file":"/packages/cli-core/package.json","types":["devDependencies"]}]}]},{"name":"js-yaml","versions":[{"version":"4.1.1","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"os","versions":[{"version":"0.1.2","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"uuid","versions":[{"version":"13.0.0","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]},{"file":"/packages/global-config/package.json","types":["dependencies"]}]}]},{"name":"validate-npm-package-name","versions":[{"version":"6.0.2","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"yesno","versions":[{"version":"0.4.0","files":[{"file":"/packages/create-webiny-project/package.json","types":["dependencies"]}]}]},{"name":"@types/js-yaml","versions":[{"version":"4.0.9","files":[{"file":"/packages/create-webiny-project/package.json","types":["devDependencies"]}]}]},{"name":"@types/validate-npm-package-name","versions":[{"version":"3.0.3","files":[{"file":"/packages/create-webiny-project/package.json","types":["devDependencies"]}]}]},{"name":"center-align","versions":[{"version":"1.0.1","files":[{"file":"/packages/data-migration/package.json","types":["dependencies"]}]}]},{"name":"pino-pretty","versions":[{"version":"9.4.1","files":[{"file":"/packages/data-migration/package.json","types":["dependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@types/center-align","versions":[{"version":"1.0.2","files":[{"file":"/packages/data-migration/package.json","types":["devDependencies"]}]}]},{"name":"@types/semver","versions":[{"version":"7.7.1","files":[{"file":"/packages/data-migration/package.json","types":["devDependencies"]}]}]},{"name":"dynamodb-toolbox","versions":[{"version":"0.9.5","files":[{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"fuse.js","versions":[{"version":"7.1.0","files":[{"file":"/packages/db-dynamodb/package.json","types":["dependencies"]}]}]},{"name":"@types/is-number","versions":[{"version":"7.0.5","files":[{"file":"/packages/db-dynamodb/package.json","types":["devDependencies"]}]}]},{"name":"@types/uniqid","versions":[{"version":"5.3.4","files":[{"file":"/packages/feature-flags/package.json","types":["devDependencies"]},{"file":"/packages/plugins/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/react","versions":[{"version":"15.0.7","files":[{"file":"/packages/form/package.json","types":["devDependencies"]},{"file":"/packages/react-composition/package.json","types":["devDependencies"]},{"file":"/packages/react-properties/package.json","types":["devDependencies"]},{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]},{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/user-event","versions":[{"version":"14.6.1","files":[{"file":"/packages/form/package.json","types":["devDependencies"]}]}]},{"name":"@types/invariant","versions":[{"version":"2.2.37","files":[{"file":"/packages/form/package.json","types":["devDependencies"]}]}]},{"name":"ci-info","versions":[{"version":"4.3.0","files":[{"file":"/packages/global-config/package.json","types":["dependencies"]},{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"@fastify/compress","versions":[{"version":"7.0.3","files":[{"file":"/packages/handler/package.json","types":["dependencies"]}]}]},{"name":"@fastify/cookie","versions":[{"version":"9.4.0","files":[{"file":"/packages/handler/package.json","types":["dependencies"]}]}]},{"name":"fastify","versions":[{"version":"4.29.1","files":[{"file":"/packages/handler/package.json","types":["dependencies"]},{"file":"/packages/handler-aws/package.json","types":["dependencies"]}]}]},{"name":"@fastify/aws-lambda","versions":[{"version":"4.1.0","files":[{"file":"/packages/handler-aws/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/resolvers-composition","versions":[{"version":"7.0.25","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"@graphql-tools/utils","versions":[{"version":"10.11.0","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"graphql-scalars","versions":[{"version":"1.25.0","files":[{"file":"/packages/handler-graphql/package.json","types":["dependencies"]}]}]},{"name":"accounting","versions":[{"version":"0.4.1","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"fecha","versions":[{"version":"2.3.3","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"short-hash","versions":[{"version":"1.0.0","files":[{"file":"/packages/i18n/package.json","types":["dependencies"]}]}]},{"name":"@types/accounting","versions":[{"version":"0.4.5","files":[{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"@types/glob","versions":[{"version":"7.2.0","files":[{"file":"/packages/i18n/package.json","types":["devDependencies"]}]}]},{"name":"inversify","versions":[{"version":"6.2.2","files":[{"file":"/packages/ioc/package.json","types":["dependencies"]}]}]},{"name":"reflect-metadata","versions":[{"version":"0.2.2","files":[{"file":"/packages/ioc/package.json","types":["dependencies"]}]}]},{"name":"@types/jsdom","versions":[{"version":"21.1.7","files":[{"file":"/packages/lexical-converter/package.json","types":["devDependencies"]},{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/prismjs","versions":[{"version":"1.26.5","files":[{"file":"/packages/lexical-nodes/package.json","types":["dependencies"]}]}]},{"name":"react-style-object-to-css","versions":[{"version":"1.1.2","files":[{"file":"/packages/lexical-theme/package.json","types":["dependencies"]}]}]},{"name":"pino","versions":[{"version":"9.13.1","files":[{"file":"/packages/logger/package.json","types":["devDependencies"]},{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]},{"file":"/scripts/cli/package.json","types":["dependencies"]}]}]},{"name":"debounce","versions":[{"version":"1.2.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"dotenv","versions":[{"version":"8.6.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"exit-hook","versions":[{"version":"4.0.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"graphql-request","versions":[{"version":"7.3.5","files":[{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"mqtt","versions":[{"version":"5.14.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"neverthrow","versions":[{"version":"8.2.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"react-test-renderer","versions":[{"version":"18.3.1","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"replace-in-path","versions":[{"version":"1.1.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]}]}]},{"name":"serialize-error","versions":[{"version":"12.0.0","files":[{"file":"/packages/project/package.json","types":["dependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"@types/debounce","versions":[{"version":"1.2.4","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/humanize-duration","versions":[{"version":"3.27.4","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/react-test-renderer","versions":[{"version":"18.3.1","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@types/read-json-sync","versions":[{"version":"2.0.3","files":[{"file":"/packages/project/package.json","types":["devDependencies"]}]}]},{"name":"@pulumi/aws","versions":[{"version":"7.12.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"@pulumi/pulumi","versions":[{"version":"3.209.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]},{"file":"/packages/pulumi/package.json","types":["dependencies"]},{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"@pulumi/random","versions":[{"version":"4.18.4","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"core-js","versions":[{"version":"3.45.1","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"cross-fetch","versions":[{"version":"3.2.0","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"regenerator-runtime","versions":[{"version":"0.14.1","files":[{"file":"/packages/project-aws/package.json","types":["dependencies"]}]}]},{"name":"@types/ncp","versions":[{"version":"2.0.8","files":[{"file":"/packages/project-aws/package.json","types":["devDependencies"]}]}]},{"name":"listr2","versions":[{"version":"5.0.8","files":[{"file":"/packages/project-utils/package.json","types":["devDependencies"]},{"file":"/scripts/buildPackages/package.json","types":["dependencies"]}]}]},{"name":"decompress","versions":[{"version":"4.2.1","files":[{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"tar","versions":[{"version":"6.2.1","files":[{"file":"/packages/pulumi-sdk/package.json","types":["dependencies"]}]}]},{"name":"identity-obj-proxy","versions":[{"version":"3.0.0","files":[{"file":"/packages/react-rich-text-lexical-renderer/package.json","types":["devDependencies"]}]}]},{"name":"cli-table3","versions":[{"version":"0.6.5","files":[{"file":"/packages/system-requirements/package.json","types":["dependencies"]}]}]},{"name":"object-merge-advanced","versions":[{"version":"12.1.0","files":[{"file":"/packages/tasks/package.json","types":["dependencies"]}]}]},{"name":"object-sizeof","versions":[{"version":"2.6.5","files":[{"file":"/packages/tasks/package.json","types":["dependencies"]}]}]},{"name":"jsesc","versions":[{"version":"3.1.0","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"strip-ansi","versions":[{"version":"6.0.1","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"wts-client","versions":[{"version":"2.0.0","files":[{"file":"/packages/telemetry/package.json","types":["dependencies"]}]}]},{"name":"ncp","versions":[{"version":"2.0.0","files":[{"file":"/packages/ui/package.json","types":["devDependencies"]}]}]},{"name":"@noble/hashes","versions":[{"version":"2.0.0","files":[{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"bson-objectid","versions":[{"version":"2.0.4","files":[{"file":"/packages/utils/package.json","types":["dependencies"]}]}]},{"name":"nanoid-dictionary","versions":[{"version":"4.3.0","files":[{"file":"/packages/utils/package.json","types":["dependencies"]},{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"isnumeric","versions":[{"version":"0.3.3","files":[{"file":"/packages/validation/package.json","types":["dependencies"]}]}]},{"name":"postcss","versions":[{"version":"8.5.6","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["dependencies"]}]}]},{"name":"postcss-import","versions":[{"version":"16.1.1","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["dependencies"]}]}]},{"name":"@types/postcss-import","versions":[{"version":"14.0.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"next","versions":[{"version":"15.5.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"webpack","versions":[{"version":"5.101.3","files":[{"file":"/packages/website-builder-nextjs/package.json","types":["devDependencies"]}]}]},{"name":"csstype","versions":[{"version":"3.1.3","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"fast-json-patch","versions":[{"version":"3.1.1","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"fast-json-stable-stringify","versions":[{"version":"2.1.0","files":[{"file":"/packages/website-builder-sdk/package.json","types":["dependencies"]}]}]},{"name":"@4tw/cypress-drag-drop","versions":[{"version":"1.8.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"@testing-library/cypress","versions":[{"version":"10.1.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"amazon-cognito-identity-js","versions":[{"version":"4.6.3","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress","versions":[{"version":"13.17.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-image-snapshot","versions":[{"version":"4.0.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-mailosaur","versions":[{"version":"2.17.0","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"cypress-wait-until","versions":[{"version":"1.7.2","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"del","versions":[{"version":"6.1.1","files":[{"file":"/cypress-tests/package.json","types":["devDependencies"]}]}]},{"name":"@types/folder-hash","versions":[{"version":"4.0.4","files":[{"file":"/scripts/buildPackages/package.json","types":["devDependencies"]}]}]},{"name":"@types/yargs","versions":[{"version":"17.0.33","files":[{"file":"/scripts/buildPackages/package.json","types":["devDependencies"]}]}]},{"name":"cli-progress","versions":[{"version":"3.12.0","files":[{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"p-limit","versions":[{"version":"7.1.1","files":[{"file":"/scripts/cjsToEsm/package.json","types":["dependencies"]}]}]},{"name":"@types/cli-progress","versions":[{"version":"3.11.6","files":[{"file":"/scripts/cjsToEsm/package.json","types":["devDependencies"]}]}]}]} From 60a1ac37e527760408828694e5136f2941c6b074 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 2 Dec 2025 14:08:42 +0100 Subject: [PATCH 67/71] fix: update tests --- packages/api-aco/__tests__/filter.so.test.ts | 6 +++--- packages/api-aco/__tests__/folder.so.test.ts | 6 +++--- .../__tests__/utils/useGraphQlHandler.ts | 20 ++++--------------- .../src/utils/createOperationsWrapper.ts | 6 ++++-- .../file/GetFile/GetFileRepository.ts | 10 +++------- .../contentModel/GetModel/GetModelUseCase.ts | 8 -------- .../__tests__/runner/websocketsRunner.test.ts | 2 +- 7 files changed, 18 insertions(+), 40 deletions(-) diff --git a/packages/api-aco/__tests__/filter.so.test.ts b/packages/api-aco/__tests__/filter.so.test.ts index 20d4c5c7939..39d45080bcb 100644 --- a/packages/api-aco/__tests__/filter.so.test.ts +++ b/packages/api-aco/__tests__/filter.so.test.ts @@ -123,7 +123,7 @@ describe("`filter` CRUD", () => { getFilter: { data: null, error: { - code: "NOT_FOUND", + code: "Cms/Entry/NotFound", data: null } } @@ -391,8 +391,8 @@ describe("`filter` CRUD", () => { expect(result.data.aco.updateFilter).toEqual({ data: null, error: { - code: "NOT_FOUND", - message: 'Entry by ID "any-id" not found.', + code: "Cms/Entry/NotFound", + message: 'Entry "any-id" was not found!', data: null } }); diff --git a/packages/api-aco/__tests__/folder.so.test.ts b/packages/api-aco/__tests__/folder.so.test.ts index d29f70c7b8c..591761a453a 100644 --- a/packages/api-aco/__tests__/folder.so.test.ts +++ b/packages/api-aco/__tests__/folder.so.test.ts @@ -228,7 +228,7 @@ describe("`folder` CRUD", () => { getFolder: { data: null, error: { - code: "NOT_FOUND", + code: "Cms/Entry/NotFound", data: null } } @@ -1012,8 +1012,8 @@ describe("`folder` CRUD", () => { expect(result.data.aco.updateFolder).toEqual({ data: null, error: { - code: "NOT_FOUND", - message: `Entry by ID "${id}" not found.`, + code: "Cms/Entry/NotFound", + message: `Entry "${id}" was not found!`, data: null } }); diff --git a/packages/api-aco/__tests__/utils/useGraphQlHandler.ts b/packages/api-aco/__tests__/utils/useGraphQlHandler.ts index 89c2ddb4903..2c7a9ac092a 100644 --- a/packages/api-aco/__tests__/utils/useGraphQlHandler.ts +++ b/packages/api-aco/__tests__/utils/useGraphQlHandler.ts @@ -55,18 +55,14 @@ import { GET_APP_MODEL } from "~tests/graphql/app.gql"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import type { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import type { CmsModel, HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; -import { - createFileManagerContext, - createFileManagerGraphQL, - FilePhysicalStoragePlugin -} from "@webiny/api-file-manager"; -import type { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; +import { createFileManagerContext, createFileManagerGraphQL } from "@webiny/api-file-manager"; import type { DecryptedWcpProjectLicense } from "@webiny/wcp/types"; import { createTestWcpLicense } from "@webiny/wcp/testing/createTestWcpLicense"; import { getDocumentClient } from "@webiny/project-utils/testing/dynamodb/index.js"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; import type { IdentityData } from "@webiny/api-core/features/security/IdentityContext/index.js"; import type { ApiCoreStorageOperations } from "@webiny/api-core/types/core.js"; +import type { FileAliasStorageOperations } from "@webiny/api-file-manager/types.js"; export interface UseGQLHandlerParams { permissions?: SecurityPermission[]; @@ -92,7 +88,7 @@ export const useGraphQlHandler = (params: UseGQLHandlerParams = {}) => { const { permissions, identity, plugins = [] } = params; const apiCoreStorage = getStorageOps("apiCore"); - const fileManagerStorage = getStorageOps("fileManager"); + const fileManagerStorage = getStorageOps("fileManager"); const cmsStorage = getStorageOps("cms"); const testProjectLicense = params.testProjectLicense || createTestWcpLicense(); @@ -114,17 +110,9 @@ export const useGraphQlHandler = (params: UseGQLHandlerParams = {}) => { }), createHeadlessCmsGraphQL(), createFileManagerContext({ - storageOperations: fileManagerStorage.storageOperations + fileAliasStorageOperations: fileManagerStorage.storageOperations }), createFileManagerGraphQL(), - /** - * Mock physical file storage plugin. - */ - new FilePhysicalStoragePlugin({ - upload: async () => {}, - - delete: async () => {} - }), createHeadlessCmsGraphQL(), createAco({ documentClient }), plugins diff --git a/packages/api-aco/src/utils/createOperationsWrapper.ts b/packages/api-aco/src/utils/createOperationsWrapper.ts index a2a88a6a5c4..6d77714eb74 100644 --- a/packages/api-aco/src/utils/createOperationsWrapper.ts +++ b/packages/api-aco/src/utils/createOperationsWrapper.ts @@ -7,12 +7,14 @@ interface CreateOperationsWrapperParams extends CreateAcoStorageOperationsParams } export const createOperationsWrapper = (params: CreateOperationsWrapperParams) => { - const { cms, modelName } = params; + const { security, cms, modelName } = params; const withModel = async ( cb: (model: CmsModel) => Promise ): Promise => { - const model = await cms.getModel(modelName); + const model = await security.withoutAuthorization(() => { + return cms.getModel(modelName); + }); if (!model) { throw new WebinyError(`Could not find "${modelName}" model.`, "MODEL_NOT_FOUND_ERROR"); diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts index 0ffc77880a8..218bed6b5b9 100644 --- a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts @@ -1,6 +1,5 @@ import { Result } from "@webiny/feature/api"; import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { GetFileRepository as RepositoryAbstraction } from "./abstractions.js"; import { FileModel } from "~/domain/file/abstractions.js"; import type { File } from "~/domain/file/types.js"; @@ -10,14 +9,11 @@ import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private getEntryById: GetEntryByIdUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async execute(id: string): Promise> { - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.getEntryById.execute(this.fileModel, `${id}#0001`); - }); + const result = await this.getEntryById.execute(this.fileModel, `${id}#0001`); if (result.isFail()) { const error = result.error; @@ -35,5 +31,5 @@ class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { export const GetFileRepository = RepositoryAbstraction.createImplementation({ implementation: GetFileRepositoryImpl, - dependencies: [GetEntryByIdUseCase, FileModel, IdentityContext] + dependencies: [GetEntryByIdUseCase, FileModel] }); diff --git a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts index 9dcd79d420c..01bcb12f5d0 100644 --- a/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts +++ b/packages/api-headless-cms/src/features/contentModel/GetModel/GetModelUseCase.ts @@ -21,14 +21,6 @@ class GetModelUseCaseImpl implements UseCaseAbstraction.Interface { ) {} async execute(modelId: string): Promise> { - // Initial access control check (no specific model yet) - const canAccess = await this.accessControl.canAccessModel({ rwd: "r" }); - if (!canAccess) { - return Result.fail(new ModelNotAuthorizedError()); - } - - // Repository uses ModelCache to fetch all models, then filters by ID - // ModelCache handles merging plugin + database models and access control const result = await this.repository.execute(modelId); if (result.isFail()) { diff --git a/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts b/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts index ca78fd85e6a..e0c2e69d786 100644 --- a/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts +++ b/packages/api-websockets/__tests__/runner/websocketsRunner.test.ts @@ -3,7 +3,7 @@ import { WebsocketsRunner } from "~/runner"; import { useHandler } from "~tests/helpers/useHandler"; import { WebsocketsEventValidator } from "~/validator"; import { MockWebsocketsEventValidator } from "~tests/mocks/MockWebsocketsEventValidator"; -import { WebsocketsContext } from "~/context"; +import { WebsocketsContext } from "~/context/WebsocketsContext.js"; import { MockWebsocketsTransport } from "~tests/mocks/MockWebsocketsTransport"; import { WebsocketsEventRoute } from "~/handler/types"; import { createWebsocketsRoutePlugin } from "~/plugins"; From c446c68736b535a9a706b2912ef68c2264fee4e2 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 2 Dec 2025 19:04:41 +0100 Subject: [PATCH 68/71] fix: update api-aco --- packages/api-aco/__tests__/flp.fm.test.ts | 18 ++--- .../__tests__/utils/expectNotAuthorized.ts | 18 ++++- packages/api-aco/src/createAcoContext.ts | 3 +- packages/api-aco/src/domain/folder/errors.ts | 26 +++++++ ...CreateEntryRevisionFromWithFlpDecorator.ts | 62 +++++++++++++++ .../decorators/CreateEntryWithFlpDecorator.ts | 51 ++++++++++++ .../DeleteEntryRevisionWithFlpDecorator.ts | 54 +++++++++++++ .../decorators/DeleteEntryWithFlpDecorator.ts | 55 +++++++++++++ .../GetEntryByIdWithFlpDecorator.ts | 53 +++++++++++++ .../decorators/GetEntryWithFlpDecorator.ts | 58 ++++++++++++++ .../GetLatestEntriesByIdsWithFlpDecorator.ts | 60 +++++++++++++++ ...etPublishedEntriesByIdsWithFlpDecorator.ts | 62 +++++++++++++++ .../ListDeletedEntriesWithFlpDecorator.ts | 47 +++++++++++ .../decorators/ListEntriesWithFlpDecorator.ts | 47 +++++++++++ .../ListLatestEntriesWithFlpDecorator.ts | 47 +++++++++++ .../ListPublishedEntriesWithFlpDecorator.ts | 47 +++++++++++ .../decorators/MoveEntryWithFlpDecorator.ts | 77 +++++++++++++++++++ .../decorators/UpdateEntryWithFlpDecorator.ts | 62 +++++++++++++++ packages/api-aco/src/features/cms/feature.ts | 42 ++++++++++ packages/api-aco/src/features/cms/index.ts | 1 + .../FolderLevelPermissions.ts | 8 +- .../decorators/CmsEntriesCrudDecorators.ts | 1 + .../utils/decorators/ListEntriesFactory.ts | 16 ++-- .../file/CreateFile/CreateFileRepository.ts | 16 ++-- .../features/file/CreateFile/abstractions.ts | 1 + .../CreateFilesInBatchRepository.ts | 20 ++--- .../file/DeleteFile/DeleteFileRepository.ts | 10 +-- .../file/GetFile/GetFileRepository.ts | 11 ++- .../src/features/file/GetFile/abstractions.ts | 1 + .../file/ListFiles/ListFilesRepository.ts | 20 ++--- .../file/ListTags/ListTagsRepository.ts | 20 ++--- .../file/UpdateFile/UpdateFileRepository.ts | 21 +++-- .../file/UpdateFile/UpdateFileUseCase.ts | 4 +- .../features/file/UpdateFile/abstractions.ts | 1 + .../entryDataFactories/createEntryData.ts | 3 +- 35 files changed, 952 insertions(+), 91 deletions(-) create mode 100644 packages/api-aco/src/domain/folder/errors.ts create mode 100644 packages/api-aco/src/features/cms/decorators/CreateEntryRevisionFromWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/CreateEntryWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/DeleteEntryRevisionWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/DeleteEntryWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/GetEntryByIdWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/GetEntryWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/GetLatestEntriesByIdsWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/GetPublishedEntriesByIdsWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/ListDeletedEntriesWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/ListEntriesWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/ListLatestEntriesWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/ListPublishedEntriesWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/MoveEntryWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/decorators/UpdateEntryWithFlpDecorator.ts create mode 100644 packages/api-aco/src/features/cms/feature.ts create mode 100644 packages/api-aco/src/features/cms/index.ts diff --git a/packages/api-aco/__tests__/flp.fm.test.ts b/packages/api-aco/__tests__/flp.fm.test.ts index ac92176dcfd..dd291c2c1d2 100644 --- a/packages/api-aco/__tests__/flp.fm.test.ts +++ b/packages/api-aco/__tests__/flp.fm.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { useGraphQlHandler } from "./utils/useGraphQlHandler"; -import { expectNotAuthorized } from "./utils/expectNotAuthorized"; +import { expectNotAuthorized, expectFileNotAuthorized } from "./utils/expectNotAuthorized"; import { mdbid } from "@webiny/utils"; import { AuthenticatedIdentity } from "@webiny/api-core/features/security/IdentityContext/index.js"; @@ -152,7 +152,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Getting files in the folder should be forbidden for identity C. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityC.fm.getFile({ id: createdFile.id }).then(([response]) => { return response.data.fileManager.getFile; }) @@ -175,7 +175,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { }); // Creating a file in the folder should be forbidden for identity C. - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityC.fm .createFile({ data: createSampleFileData({ @@ -190,7 +190,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Updating a file in the folder should be forbidden for identity C. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityC.fm .updateFile({ id: createdFile.id, @@ -205,7 +205,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Deleting a file in the folder should be forbidden for identity C. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityC.fm.deleteFile({ id: createdFile.id }).then(([response]) => { return response.data.fileManager.deleteFile; }) @@ -349,7 +349,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Getting files in the folder should be forbidden for identity B. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityB.fm.getFile({ id: createdFile.id }).then(([response]) => { return response.data.fileManager.getFile; }) @@ -372,7 +372,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { }); // Creating a file in the folder should be forbidden for identity B. - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityB.fm .createFile({ data: createSampleFileData({ @@ -387,7 +387,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Updating a file in the folder should be forbidden for identity B. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityB.fm .updateFile({ id: createdFile.id, @@ -402,7 +402,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { // Deleting a file in the folder should be forbidden for identity B. for (let i = 0; i < createdFiles.length; i++) { const createdFile = createdFiles[i]; - await expectNotAuthorized( + await expectFileNotAuthorized( gqlIdentityB.fm.deleteFile({ id: createdFile.id }).then(([response]) => { return response.data.fileManager.deleteFile; }) diff --git a/packages/api-aco/__tests__/utils/expectNotAuthorized.ts b/packages/api-aco/__tests__/utils/expectNotAuthorized.ts index b0846419616..62e564bfece 100644 --- a/packages/api-aco/__tests__/utils/expectNotAuthorized.ts +++ b/packages/api-aco/__tests__/utils/expectNotAuthorized.ts @@ -7,9 +7,23 @@ export const expectNotAuthorized = async ( await expect(promise).resolves.toMatchObject({ data: null, error: { - code: "NOT_AUTHORIZED", + code: "Aco/Folder/NotAuthorizedError", data, - message: "Not authorized!" + message: "Not authorized." + } + }); +}; + +export const expectFileNotAuthorized = async ( + promise: Promise, + data: Record | null = null +) => { + await expect(promise).resolves.toMatchObject({ + data: null, + error: { + code: "FileManager/File/NotAuthorizedError", + data, + message: "Not authorized." } }); }; diff --git a/packages/api-aco/src/createAcoContext.ts b/packages/api-aco/src/createAcoContext.ts index 8177cc1f0b9..372dc2aa8c0 100644 --- a/packages/api-aco/src/createAcoContext.ts +++ b/packages/api-aco/src/createAcoContext.ts @@ -34,6 +34,7 @@ import { GetFlpFeature } from "~/features/flp/GetFlp/feature.js"; import { ListFolderLevelPermissionsTargetsFeature } from "~/features/folders/ListFolderLevelPermissionsTargets/feature.js"; import { Tenant } from "@webiny/api-core/types/tenancy"; import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; +import { CmsFlpFeature } from "~/features/cms/index.js"; interface CreateAcoContextParams { useFolderLevelPermissions?: boolean; @@ -146,7 +147,7 @@ const setupAcoContext = async ( }; if (context.wcp.canUseFolderLevelPermissions()) { - new CmsEntriesCrudDecorators({ context }).decorate(); + CmsFlpFeature.register(context.container); } }; diff --git a/packages/api-aco/src/domain/folder/errors.ts b/packages/api-aco/src/domain/folder/errors.ts new file mode 100644 index 00000000000..f1dbe83d120 --- /dev/null +++ b/packages/api-aco/src/domain/folder/errors.ts @@ -0,0 +1,26 @@ +import { BaseError } from "@webiny/feature/api/index.js"; + +export class FolderNotFoundError extends BaseError<{ folder: { id: string } }> { + override readonly code = "Aco/Folder/NotFound" as const; + + constructor(id: string) { + super({ + message: "Folder not found!", + data: { + folder: { + id + } + } + }); + } +} + +export class FolderNotAuthorizedError extends BaseError { + override readonly code = "Aco/Folder/NotAuthorizedError" as const; + + constructor() { + super({ + message: `Not authorized.` + }); + } +} diff --git a/packages/api-aco/src/features/cms/decorators/CreateEntryRevisionFromWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/CreateEntryRevisionFromWithFlpDecorator.ts new file mode 100644 index 00000000000..33d6866c6da --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/CreateEntryRevisionFromWithFlpDecorator.ts @@ -0,0 +1,62 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { CreateEntryRevisionFromUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntryRevisionFrom/abstractions.js"; +import { GetRevisionByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetRevisionById/abstractions.js"; +import type { + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class CreateEntryRevisionFromWithFlpDecoratorImpl + implements CreateEntryRevisionFromUseCase.Interface +{ + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private decoratee: CreateEntryRevisionFromUseCase.Interface + ) {} + + async execute( + model: CmsModel, + sourceId: string, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, sourceId, input, options); + } + + const entryResult = await this.getRevisionById.execute(model, sourceId); + if (entryResult.isFail()) { + return this.decoratee.execute(model, sourceId, input, options); + } + + const entry = entryResult.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return this.decoratee.execute(model, sourceId, input, options); + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolderContent = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "w" + }); + + if (!canAccessFolderContent) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return this.decoratee.execute(model, sourceId, input, options); + } +} + +export const CreateEntryRevisionFromWithFlpDecorator = createDecorator({ + abstraction: CreateEntryRevisionFromUseCase, + decorator: CreateEntryRevisionFromWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions, GetRevisionByIdUseCase] +}); diff --git a/packages/api-aco/src/features/cms/decorators/CreateEntryWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/CreateEntryWithFlpDecorator.ts new file mode 100644 index 00000000000..050e22627a7 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/CreateEntryWithFlpDecorator.ts @@ -0,0 +1,51 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry/abstractions.js"; +import type { + CmsModel, + CreateCmsEntryInput, + CreateCmsEntryOptionsInput +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class CreateEntryWithFlpDecoratorImpl implements CreateEntryUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: CreateEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + input: CreateCmsEntryInput, + options?: CreateCmsEntryOptionsInput + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, input, options); + } + + const folderId = input.wbyAco_location?.folderId || input.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return this.decoratee.execute(model, input, options); + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "w" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return this.decoratee.execute(model, input, options); + } +} + +export const CreateEntryWithFlpDecorator = createDecorator({ + abstraction: CreateEntryUseCase, + decorator: CreateEntryWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/DeleteEntryRevisionWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/DeleteEntryRevisionWithFlpDecorator.ts new file mode 100644 index 00000000000..1304e79be20 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/DeleteEntryRevisionWithFlpDecorator.ts @@ -0,0 +1,54 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { DeleteEntryRevisionUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntryRevision/abstractions.js"; +import { GetRevisionByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetRevisionById/abstractions.js"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class DeleteEntryRevisionWithFlpDecoratorImpl implements DeleteEntryRevisionUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private decoratee: DeleteEntryRevisionUseCase.Interface + ) {} + + async execute( + model: CmsModel, + revisionId: string + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, revisionId); + } + + const entryResult = await this.getRevisionById.execute(model, revisionId); + if (entryResult.isFail()) { + return this.decoratee.execute(model, revisionId); + } + + const entry = entryResult.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return this.decoratee.execute(model, revisionId); + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "d" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return this.decoratee.execute(model, revisionId); + } +} + +export const DeleteEntryRevisionWithFlpDecorator = createDecorator({ + abstraction: DeleteEntryRevisionUseCase, + decorator: DeleteEntryRevisionWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions, GetRevisionByIdUseCase] +}); diff --git a/packages/api-aco/src/features/cms/decorators/DeleteEntryWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/DeleteEntryWithFlpDecorator.ts new file mode 100644 index 00000000000..2ab6ccf8c59 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/DeleteEntryWithFlpDecorator.ts @@ -0,0 +1,55 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry/abstractions.js"; +import { GetLatestRevisionByEntryIdBaseUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetLatestRevisionByEntryId/abstractions.js"; +import type { CmsModel, CmsDeleteEntryOptions } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class DeleteEntryWithFlpDecoratorImpl implements DeleteEntryUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private getLatestRevisionByEntryId: GetLatestRevisionByEntryIdBaseUseCase.Interface, + private decoratee: DeleteEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + options?: CmsDeleteEntryOptions + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, id, options); + } + + const entryResult = await this.getLatestRevisionByEntryId.execute(model, { id }); + if (entryResult.isFail()) { + return this.decoratee.execute(model, id, options); + } + + const entry = entryResult.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return this.decoratee.execute(model, id, options); + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "d" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return this.decoratee.execute(model, id, options); + } +} + +export const DeleteEntryWithFlpDecorator = createDecorator({ + abstraction: DeleteEntryUseCase, + decorator: DeleteEntryWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions, GetLatestRevisionByEntryIdBaseUseCase] +}); diff --git a/packages/api-aco/src/features/cms/decorators/GetEntryByIdWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/GetEntryByIdWithFlpDecorator.ts new file mode 100644 index 00000000000..1dfbf96a337 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/GetEntryByIdWithFlpDecorator.ts @@ -0,0 +1,53 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById/abstractions.js"; +import type { CmsModel, CmsEntryValues, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class GetEntryByIdWithFlpDecoratorImpl implements GetEntryByIdUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: GetEntryByIdUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string + ): Promise, GetEntryByIdUseCase.Error>> { + const result = await this.decoratee.execute(model, id); + + if (result.isFail()) { + return result; + } + + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return result; + } + + const entry = result.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return result; + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "r" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return result; + } +} + +export const GetEntryByIdWithFlpDecorator = createDecorator({ + abstraction: GetEntryByIdUseCase, + decorator: GetEntryByIdWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/GetEntryWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/GetEntryWithFlpDecorator.ts new file mode 100644 index 00000000000..06d87673edd --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/GetEntryWithFlpDecorator.ts @@ -0,0 +1,58 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { GetEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntry/abstractions.js"; +import type { + CmsModel, + CmsEntryGetParams, + CmsEntryValues, + CmsEntry +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors"; + +class GetEntryWithFlpDecoratorImpl implements GetEntryUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: GetEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + params: CmsEntryGetParams + ): Promise, GetEntryUseCase.Error>> { + const result = await this.decoratee.execute(model, params); + + if (result.isFail()) { + return result; + } + + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return result; + } + + const entry = result.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return result; + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "r" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return result; + } +} + +export const GetEntryWithFlpDecorator = createDecorator({ + abstraction: GetEntryUseCase, + decorator: GetEntryWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/GetLatestEntriesByIdsWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/GetLatestEntriesByIdsWithFlpDecorator.ts new file mode 100644 index 00000000000..952d3549f11 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/GetLatestEntriesByIdsWithFlpDecorator.ts @@ -0,0 +1,60 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { GetLatestEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetLatestEntriesByIds/abstractions.js"; +import type { CmsModel, CmsEntryValues, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; + +class GetLatestEntriesByIdsWithFlpDecoratorImpl implements GetLatestEntriesByIdsUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: GetLatestEntriesByIdsUseCase.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], GetLatestEntriesByIdsUseCase.Error>> { + const result = await this.decoratee.execute(model, ids); + + if (result.isFail()) { + return result; + } + + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return result; + } + + const entries = result.value; + const filteredEntries = await this.filterEntriesByFolder(entries); + + return Result.ok(filteredEntries); + } + + private async filterEntriesByFolder(entries: CmsEntry[]): Promise[]> { + const results = await Promise.all( + entries.map(async entry => { + const folderId = entry.location?.folderId; + if (!folderId || folderId === ROOT_FOLDER) { + return entry; + } + + const permissions = + await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccess = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "r" + }); + return canAccess ? entry : null; + }) + ); + + return results.filter((entry): entry is CmsEntry => !!entry); + } +} + +export const GetLatestEntriesByIdsWithFlpDecorator = createDecorator({ + abstraction: GetLatestEntriesByIdsUseCase, + decorator: GetLatestEntriesByIdsWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/GetPublishedEntriesByIdsWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/GetPublishedEntriesByIdsWithFlpDecorator.ts new file mode 100644 index 00000000000..2900f711809 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/GetPublishedEntriesByIdsWithFlpDecorator.ts @@ -0,0 +1,62 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { GetPublishedEntriesByIdsUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetPublishedEntriesByIds/abstractions.js"; +import type { CmsModel, CmsEntryValues, CmsEntry } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; + +class GetPublishedEntriesByIdsWithFlpDecoratorImpl + implements GetPublishedEntriesByIdsUseCase.Interface +{ + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: GetPublishedEntriesByIdsUseCase.Interface + ) {} + + async execute( + model: CmsModel, + ids: string[] + ): Promise[], GetPublishedEntriesByIdsUseCase.Error>> { + const result = await this.decoratee.execute(model, ids); + + if (result.isFail()) { + return result; + } + + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return result; + } + + const entries = result.value; + const filteredEntries = await this.filterEntriesByFolder(entries); + + return Result.ok(filteredEntries); + } + + private async filterEntriesByFolder(entries: CmsEntry[]): Promise[]> { + const results = await Promise.all( + entries.map(async entry => { + const folderId = entry.location?.folderId; + if (!folderId || folderId === ROOT_FOLDER) { + return entry; + } + + const permissions = + await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccess = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "r" + }); + return canAccess ? entry : null; + }) + ); + + return results.filter((entry): entry is CmsEntry => !!entry); + } +} + +export const GetPublishedEntriesByIdsWithFlpDecorator = createDecorator({ + abstraction: GetPublishedEntriesByIdsUseCase, + decorator: GetPublishedEntriesByIdsWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/ListDeletedEntriesWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/ListDeletedEntriesWithFlpDecorator.ts new file mode 100644 index 00000000000..e2f5e1724ce --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/ListDeletedEntriesWithFlpDecorator.ts @@ -0,0 +1,47 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ListDeletedEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/abstractions.js"; +import type { + CmsModel, + CmsEntryValues, + CmsEntryListParams, + CmsEntry, + CmsEntryMeta +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ListEntriesFactory } from "~/utils/decorators/ListEntriesFactory.js"; + +class ListDeletedEntriesWithFlpDecoratorImpl implements ListDeletedEntriesUseCase.Interface { + private readonly listEntriesHandler: ListEntriesFactory; + + constructor( + folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: ListDeletedEntriesUseCase.Interface + ) { + this.listEntriesHandler = new ListEntriesFactory(folderLevelPermissions); + } + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], ListDeletedEntriesUseCase.Error>> { + const loader = async (params?: CmsEntryListParams) => { + const result = await this.decoratee.execute(model, params); + return result.value; + }; + + const [entries, meta] = await this.listEntriesHandler.execute({ + model, + dataLoader: loader, + initialParams: params + }); + + return Result.ok([entries, meta]) as Result<[CmsEntry[], CmsEntryMeta]>; + } +} + +export const ListDeletedEntriesWithFlpDecorator = createDecorator({ + abstraction: ListDeletedEntriesUseCase, + decorator: ListDeletedEntriesWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/ListEntriesWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/ListEntriesWithFlpDecorator.ts new file mode 100644 index 00000000000..8829cd40df7 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/ListEntriesWithFlpDecorator.ts @@ -0,0 +1,47 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ListEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/abstractions.js"; +import type { + CmsModel, + CmsEntryValues, + CmsEntryListParams, + CmsEntry, + CmsEntryMeta +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ListEntriesFactory } from "~/utils/decorators/ListEntriesFactory.js"; + +class ListEntriesWithFlpDecoratorImpl implements ListEntriesUseCase.Interface { + private readonly listEntriesHandler: ListEntriesFactory; + + constructor( + folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: ListEntriesUseCase.Interface + ) { + this.listEntriesHandler = new ListEntriesFactory(folderLevelPermissions); + } + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], ListEntriesUseCase.Error>> { + const loader = async (params?: CmsEntryListParams) => { + const result = await this.decoratee.execute(model, params); + return result.value; + }; + + const [entries, meta] = await this.listEntriesHandler.execute({ + model, + dataLoader: loader, + initialParams: params + }); + + return Result.ok([entries, meta]) as Result<[CmsEntry[], CmsEntryMeta]>; + } +} + +export const ListEntriesWithFlpDecorator = createDecorator({ + abstraction: ListEntriesUseCase, + decorator: ListEntriesWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/ListLatestEntriesWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/ListLatestEntriesWithFlpDecorator.ts new file mode 100644 index 00000000000..ceadeff5ec9 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/ListLatestEntriesWithFlpDecorator.ts @@ -0,0 +1,47 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/abstractions.js"; +import type { + CmsModel, + CmsEntryValues, + CmsEntryListParams, + CmsEntry, + CmsEntryMeta +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ListEntriesFactory } from "~/utils/decorators/ListEntriesFactory.js"; + +class ListLatestEntriesWithFlpDecoratorImpl implements ListLatestEntriesUseCase.Interface { + private readonly listEntriesHandler: ListEntriesFactory; + + constructor( + folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: ListLatestEntriesUseCase.Interface + ) { + this.listEntriesHandler = new ListEntriesFactory(folderLevelPermissions); + } + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], ListLatestEntriesUseCase.Error>> { + const loader = async (params?: CmsEntryListParams) => { + const result = await this.decoratee.execute(model, params); + return result.value; + }; + + const [entries, meta] = await this.listEntriesHandler.execute({ + model, + dataLoader: loader, + initialParams: params + }); + + return Result.ok([entries, meta]) as Result<[CmsEntry[], CmsEntryMeta]>; + } +} + +export const ListLatestEntriesWithFlpDecorator = createDecorator({ + abstraction: ListLatestEntriesUseCase, + decorator: ListLatestEntriesWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/ListPublishedEntriesWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/ListPublishedEntriesWithFlpDecorator.ts new file mode 100644 index 00000000000..544c0025da0 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/ListPublishedEntriesWithFlpDecorator.ts @@ -0,0 +1,47 @@ +import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; +import { ListPublishedEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/abstractions.js"; +import type { + CmsModel, + CmsEntryValues, + CmsEntryListParams, + CmsEntry, + CmsEntryMeta +} from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ListEntriesFactory } from "~/utils/decorators/ListEntriesFactory.js"; + +class ListPublishedEntriesWithFlpDecoratorImpl implements ListPublishedEntriesUseCase.Interface { + private readonly listEntriesHandler: ListEntriesFactory; + + constructor( + folderLevelPermissions: FolderLevelPermissions.Interface, + private decoratee: ListPublishedEntriesUseCase.Interface + ) { + this.listEntriesHandler = new ListEntriesFactory(folderLevelPermissions); + } + + async execute( + model: CmsModel, + params?: CmsEntryListParams + ): Promise[], CmsEntryMeta], ListPublishedEntriesUseCase.Error>> { + const loader = async (params?: CmsEntryListParams) => { + const result = await this.decoratee.execute(model, params); + return result.value; + }; + + const [entries, meta] = await this.listEntriesHandler.execute({ + model, + dataLoader: loader, + initialParams: params + }); + + return Result.ok([entries, meta]) as Result<[CmsEntry[], CmsEntryMeta]>; + } +} + +export const ListPublishedEntriesWithFlpDecorator = createDecorator({ + abstraction: ListPublishedEntriesUseCase, + decorator: ListPublishedEntriesWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions] +}); diff --git a/packages/api-aco/src/features/cms/decorators/MoveEntryWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/MoveEntryWithFlpDecorator.ts new file mode 100644 index 00000000000..8113b2a87c4 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/MoveEntryWithFlpDecorator.ts @@ -0,0 +1,77 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { MoveEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/MoveEntry/abstractions.js"; +import { GetRevisionByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetRevisionById/abstractions.js"; +import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class MoveEntryWithFlpDecoratorImpl implements MoveEntryUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private decoratee: MoveEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + targetFolderId: string + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, id, targetFolderId); + } + + // First, get the entry to check its current folder + const entryResult = await this.getRevisionById.execute(model, id); + if (entryResult.isFail()) { + return this.decoratee.execute(model, id, targetFolderId); + } + + const entry = entryResult.value; + const currentFolderId = entry?.location?.folderId || ROOT_FOLDER; + + // If the entry is in the same folder we are trying to move it to, just continue + if (currentFolderId === targetFolderId) { + return this.decoratee.execute(model, id, targetFolderId); + } + + // If current folder is not ROOT, check for access + if (currentFolderId !== ROOT_FOLDER) { + const permissions = + await this.folderLevelPermissions.getFolderLevelPermissions(currentFolderId); + + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "w" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + } + + // If target folder is not ROOT, check for access + if (targetFolderId !== ROOT_FOLDER) { + const permissions = + await this.folderLevelPermissions.getFolderLevelPermissions(targetFolderId); + + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "w" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + } + + return this.decoratee.execute(model, id, targetFolderId); + } +} + +export const MoveEntryWithFlpDecorator = createDecorator({ + abstraction: MoveEntryUseCase, + decorator: MoveEntryWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions, GetRevisionByIdUseCase] +}); diff --git a/packages/api-aco/src/features/cms/decorators/UpdateEntryWithFlpDecorator.ts b/packages/api-aco/src/features/cms/decorators/UpdateEntryWithFlpDecorator.ts new file mode 100644 index 00000000000..33d861432d7 --- /dev/null +++ b/packages/api-aco/src/features/cms/decorators/UpdateEntryWithFlpDecorator.ts @@ -0,0 +1,62 @@ +import { createDecorator, Result } from "@webiny/feature/api"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry/abstractions.js"; +import { GetRevisionByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetRevisionById/abstractions.js"; +import type { + CmsModel, + UpdateCmsEntryInput, + UpdateCmsEntryOptionsInput +} from "@webiny/api-headless-cms/types/index.js"; +import type { GenericRecord } from "@webiny/api/types.js"; +import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { ROOT_FOLDER } from "~/constants.js"; +import { EntryNotAuthorizedError } from "@webiny/api-headless-cms/domain/contentEntry/errors.js"; + +class UpdateEntryWithFlpDecoratorImpl implements UpdateEntryUseCase.Interface { + constructor( + private folderLevelPermissions: FolderLevelPermissions.Interface, + private getRevisionById: GetRevisionByIdUseCase.Interface, + private decoratee: UpdateEntryUseCase.Interface + ) {} + + async execute( + model: CmsModel, + id: string, + input: UpdateCmsEntryInput, + metaInput?: GenericRecord, + options?: UpdateCmsEntryOptionsInput + ): ReturnType { + if (!this.folderLevelPermissions.canUseFolderLevelPermissions()) { + return this.decoratee.execute(model, id, input, metaInput, options); + } + + const entryResult = await this.getRevisionById.execute(model, id); + if (entryResult.isFail()) { + return this.decoratee.execute(model, id, input, metaInput, options); + } + + const entry = entryResult.value; + const folderId = entry?.location?.folderId; + + if (!folderId || folderId === ROOT_FOLDER) { + return this.decoratee.execute(model, id, input, metaInput, options); + } + + const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folderId); + const canAccessFolder = await this.folderLevelPermissions.canAccessFolderContent({ + permissions, + rwd: "w" + }); + + if (!canAccessFolder) { + return Result.fail(new EntryNotAuthorizedError()); + } + + return this.decoratee.execute(model, id, input, metaInput, options); + } +} + +export const UpdateEntryWithFlpDecorator = createDecorator({ + abstraction: UpdateEntryUseCase, + decorator: UpdateEntryWithFlpDecoratorImpl, + dependencies: [FolderLevelPermissions, GetRevisionByIdUseCase] +}); diff --git a/packages/api-aco/src/features/cms/feature.ts b/packages/api-aco/src/features/cms/feature.ts new file mode 100644 index 00000000000..4bca8743c48 --- /dev/null +++ b/packages/api-aco/src/features/cms/feature.ts @@ -0,0 +1,42 @@ +import { createFeature } from "@webiny/feature/api"; +import { CreateEntryWithFlpDecorator } from "./decorators/CreateEntryWithFlpDecorator.js"; +import { CreateEntryRevisionFromWithFlpDecorator } from "./decorators/CreateEntryRevisionFromWithFlpDecorator.js"; +import { UpdateEntryWithFlpDecorator } from "./decorators/UpdateEntryWithFlpDecorator.js"; +import { DeleteEntryWithFlpDecorator } from "./decorators/DeleteEntryWithFlpDecorator.js"; +import { DeleteEntryRevisionWithFlpDecorator } from "./decorators/DeleteEntryRevisionWithFlpDecorator.js"; +import { MoveEntryWithFlpDecorator } from "./decorators/MoveEntryWithFlpDecorator.js"; +import { GetEntryWithFlpDecorator } from "./decorators/GetEntryWithFlpDecorator.js"; +import { GetEntryByIdWithFlpDecorator } from "./decorators/GetEntryByIdWithFlpDecorator.js"; +import { GetLatestEntriesByIdsWithFlpDecorator } from "./decorators/GetLatestEntriesByIdsWithFlpDecorator.js"; +import { GetPublishedEntriesByIdsWithFlpDecorator } from "./decorators/GetPublishedEntriesByIdsWithFlpDecorator.js"; +import { ListLatestEntriesWithFlpDecorator } from "./decorators/ListLatestEntriesWithFlpDecorator.js"; +import { ListPublishedEntriesWithFlpDecorator } from "./decorators/ListPublishedEntriesWithFlpDecorator.js"; +import { ListDeletedEntriesWithFlpDecorator } from "./decorators/ListDeletedEntriesWithFlpDecorator.js"; +import { ListEntriesWithFlpDecorator } from "./decorators/ListEntriesWithFlpDecorator.js"; + +export const CmsFlpFeature = createFeature({ + name: "Aco/CmsFlp", + register(container) { + // Command decorators + container.registerDecorator(CreateEntryWithFlpDecorator); + container.registerDecorator(CreateEntryRevisionFromWithFlpDecorator); + container.registerDecorator(UpdateEntryWithFlpDecorator); + container.registerDecorator(DeleteEntryWithFlpDecorator); + container.registerDecorator(DeleteEntryRevisionWithFlpDecorator); + container.registerDecorator(MoveEntryWithFlpDecorator); + + // Query decorators - single entry + container.registerDecorator(GetEntryWithFlpDecorator); + container.registerDecorator(GetEntryByIdWithFlpDecorator); + + // Query decorators - multiple entries + container.registerDecorator(GetLatestEntriesByIdsWithFlpDecorator); + container.registerDecorator(GetPublishedEntriesByIdsWithFlpDecorator); + + // Query decorators - list entries + container.registerDecorator(ListEntriesWithFlpDecorator); + container.registerDecorator(ListLatestEntriesWithFlpDecorator); + container.registerDecorator(ListPublishedEntriesWithFlpDecorator); + container.registerDecorator(ListDeletedEntriesWithFlpDecorator); + } +}); diff --git a/packages/api-aco/src/features/cms/index.ts b/packages/api-aco/src/features/cms/index.ts new file mode 100644 index 00000000000..891e69f5917 --- /dev/null +++ b/packages/api-aco/src/features/cms/index.ts @@ -0,0 +1 @@ +export { CmsFlpFeature } from "./feature.js"; diff --git a/packages/api-aco/src/features/flp/FolderLevelPermissions/FolderLevelPermissions.ts b/packages/api-aco/src/features/flp/FolderLevelPermissions/FolderLevelPermissions.ts index e9f5f367b70..8082449d77a 100644 --- a/packages/api-aco/src/features/flp/FolderLevelPermissions/FolderLevelPermissions.ts +++ b/packages/api-aco/src/features/flp/FolderLevelPermissions/FolderLevelPermissions.ts @@ -76,10 +76,10 @@ class FolderLevelPermissionsImpl implements FolderLevelPermissionsAbstraction.In } public async canAccessFolderContent(params: CanAccessFolderContentParams): Promise { - if ( - !this.canUseFolderLevelPermissions() || - !this.identityContext.isAuthorizationEnabled() - ) { + const canUseFlp = this.canUseFolderLevelPermissions(); + const authEnabled = this.identityContext.isAuthorizationEnabled(); + + if (!canUseFlp || !authEnabled) { return true; } diff --git a/packages/api-aco/src/utils/decorators/CmsEntriesCrudDecorators.ts b/packages/api-aco/src/utils/decorators/CmsEntriesCrudDecorators.ts index a06a3921e1d..ba67213a7c0 100644 --- a/packages/api-aco/src/utils/decorators/CmsEntriesCrudDecorators.ts +++ b/packages/api-aco/src/utils/decorators/CmsEntriesCrudDecorators.ts @@ -1,3 +1,4 @@ +// @ts-nocheck Being removed import type { AcoContext } from "~/types.js"; import { ROOT_FOLDER } from "~/constants.js"; import { ListEntriesFactory } from "./ListEntriesFactory.js"; diff --git a/packages/api-aco/src/utils/decorators/ListEntriesFactory.ts b/packages/api-aco/src/utils/decorators/ListEntriesFactory.ts index cdaf432ddae..8defcb138d0 100644 --- a/packages/api-aco/src/utils/decorators/ListEntriesFactory.ts +++ b/packages/api-aco/src/utils/decorators/ListEntriesFactory.ts @@ -1,8 +1,7 @@ import type { CmsEntry, CmsEntryListParams, - CmsEntryMeta, - CmsEntryValues + CmsEntryMeta } from "@webiny/api-headless-cms/types/index.js"; import { type CmsModel } from "@webiny/api-headless-cms/types/index.js"; import { hasRootFolderId } from "~/utils/decorators/hasRootFolderId.js"; @@ -10,11 +9,8 @@ import type { FolderPermission } from "~/flp/flp.types.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; interface ListEntriesFactoryCallbackParams { - decoratee: ( - model: CmsModel, - params: CmsEntryListParams - ) => Promise<[CmsEntry[], CmsEntryMeta]>; model: CmsModel; + dataLoader: (params?: CmsEntryListParams) => Promise<[CmsEntry[], CmsEntryMeta]>; initialParams?: CmsEntryListParams; } @@ -28,8 +24,8 @@ export class ListEntriesFactory { } public async execute({ - decoratee, model, + dataLoader, initialParams = {} }: ListEntriesFactoryCallbackParams): Promise<[CmsEntry[], CmsEntryMeta]> { const limit = initialParams?.limit || 50; @@ -39,7 +35,7 @@ export class ListEntriesFactory { // If FLP should be skipped, or we're querying the root folder, skip permission checks if (!this.folderLevelPermissions.canUseFolderLevelPermissions() || hasRootFolder) { - return await decoratee(model, params); + return dataLoader(params); } const resultEntries: CmsEntry[] = []; @@ -52,7 +48,7 @@ export class ListEntriesFactory { // Process entries in batches until we have enough results or reach the end while (!fetchedAll) { const queryParams: CmsEntryListParams = { ...params, after: afterCursor }; - const [entries, currentMeta] = await decoratee(model, queryParams); + const [entries, currentMeta] = await dataLoader(queryParams); if (totalCount === 0) { totalCount = currentMeta.totalCount; @@ -101,7 +97,7 @@ export class ListEntriesFactory { } } - return [resultEntries, { totalCount, hasMoreItems, cursor } as CmsEntryMeta]; + return [resultEntries, { totalCount, hasMoreItems, cursor }]; } private async getPermissions(folderId: string): Promise { diff --git a/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts index cc360602256..74bd275bcf3 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/CreateFileRepository.ts @@ -1,25 +1,25 @@ import { Result } from "@webiny/feature/api"; import { CreateFileRepository as RepositoryAbstraction } from "./abstractions.js"; import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { FileModel } from "~/domain/file/abstractions.js"; import type { File, FileInput } from "~/domain/file/types.js"; import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; -import { FilePersistenceError } from "~/domain/file/errors.js"; +import { FileNotAuthorizedError, FilePersistenceError } from "~/domain/file/errors.js"; class CreateFileRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private createEntry: CreateEntryUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async execute(data: FileInput): Promise> { - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.createEntry.execute(this.fileModel, data); - }); + const result = await this.createEntry.execute(this.fileModel, data); if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotAuthorized") { + return Result.fail(new FileNotAuthorizedError()); + } + return Result.fail(new FilePersistenceError(result.error)); } @@ -30,5 +30,5 @@ class CreateFileRepositoryImpl implements RepositoryAbstraction.Interface { export const CreateFileRepository = RepositoryAbstraction.createImplementation({ implementation: CreateFileRepositoryImpl, - dependencies: [CreateEntryUseCase, FileModel, IdentityContext] + dependencies: [CreateEntryUseCase, FileModel] }); diff --git a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts index fabd2eb1ce7..3063b6a4b26 100644 --- a/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/CreateFile/abstractions.ts @@ -36,6 +36,7 @@ export interface ICreateFileRepository { } export interface ICreateFileRepositoryErrors { + notAuthorized: FileNotAuthorizedError; persistence: FilePersistenceError; } diff --git a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts index 4caec15e88f..b8d2c79f38f 100644 --- a/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts +++ b/packages/api-file-manager/src/features/file/CreateFilesInBatch/CreateFilesInBatchRepository.ts @@ -1,23 +1,17 @@ import { Result } from "@webiny/feature/api"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { CreateFilesInBatchRepository as RepositoryAbstraction } from "./abstractions.js"; import type { File, FileInput } from "~/domain/file/types.js"; import { CreateFileRepository } from "~/features/file/CreateFile/abstractions.js"; class CreateFilesInBatchRepositoryImpl implements RepositoryAbstraction.Interface { - constructor( - private createFileRepository: CreateFileRepository.Interface, - private identityContext: IdentityContext.Interface - ) {} + constructor(private createFileRepository: CreateFileRepository.Interface) {} async createBatch(files: FileInput[]): Promise> { - const results = await this.identityContext.withoutAuthorization(async () => { - return await Promise.all( - files.map(async input => { - return this.createFileRepository.execute(input); - }) - ); - }); + const results = await Promise.all( + files.map(async input => { + return this.createFileRepository.execute(input); + }) + ); // Return only successful results. // TODO: group files into successful and failed @@ -29,5 +23,5 @@ class CreateFilesInBatchRepositoryImpl implements RepositoryAbstraction.Interfac export const CreateFilesInBatchRepository = RepositoryAbstraction.createImplementation({ implementation: CreateFilesInBatchRepositoryImpl, - dependencies: [CreateFileRepository, IdentityContext] + dependencies: [CreateFileRepository] }); diff --git a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts index 0db3bb48bc4..6c270f0187e 100644 --- a/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts +++ b/packages/api-file-manager/src/features/file/DeleteFile/DeleteFileRepository.ts @@ -1,6 +1,5 @@ import { Result } from "@webiny/feature/api"; import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { DeleteFileRepository as RepositoryAbstraction } from "./abstractions.js"; import { FileModel } from "~/domain/file/abstractions.js"; import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; @@ -9,17 +8,14 @@ import { File } from "~/domain/file/types.js"; class DeleteFileRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private deleteEntry: DeleteEntryUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async delete(file: File): Promise> { // Files are not versioned, so we're always deleting the same revision const entryId = `${file.id}#0001`; - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.deleteEntry.execute(this.fileModel, entryId); - }); + const result = await this.deleteEntry.execute(this.fileModel, entryId); if (result.isFail()) { const error = result.error; @@ -35,5 +31,5 @@ class DeleteFileRepositoryImpl implements RepositoryAbstraction.Interface { export const DeleteFileRepository = RepositoryAbstraction.createImplementation({ implementation: DeleteFileRepositoryImpl, - dependencies: [DeleteEntryUseCase, FileModel, IdentityContext] + dependencies: [DeleteEntryUseCase, FileModel] }); diff --git a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts index 218bed6b5b9..e95b660033b 100644 --- a/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts +++ b/packages/api-file-manager/src/features/file/GetFile/GetFileRepository.ts @@ -3,7 +3,11 @@ import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEn import { GetFileRepository as RepositoryAbstraction } from "./abstractions.js"; import { FileModel } from "~/domain/file/abstractions.js"; import type { File } from "~/domain/file/types.js"; -import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; +import { + FileNotAuthorizedError, + FileNotFoundError, + FilePersistenceError +} from "~/domain/file/errors.js"; import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { @@ -17,9 +21,14 @@ class GetFileRepositoryImpl implements RepositoryAbstraction.Interface { if (result.isFail()) { const error = result.error; + if (error.code === "Cms/Entry/NotFound") { return Result.fail(new FileNotFoundError(id)); } + + if (error.code === "Cms/Entry/NotAuthorized") { + return Result.fail(new FileNotAuthorizedError()); + } return Result.fail(new FilePersistenceError(result.error)); } diff --git a/packages/api-file-manager/src/features/file/GetFile/abstractions.ts b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts index 93e7651a78d..3a0ae7bbe4d 100644 --- a/packages/api-file-manager/src/features/file/GetFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/GetFile/abstractions.ts @@ -16,6 +16,7 @@ export interface IGetFileRepository { export interface IGetFileRepositoryErrors { notFound: FileNotFoundError; + notAuthorized: FileNotAuthorizedError; persistence: FilePersistenceError; } diff --git a/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts b/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts index 601641ccf40..57b1a52b21f 100644 --- a/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts +++ b/packages/api-file-manager/src/features/file/ListFiles/ListFilesRepository.ts @@ -1,5 +1,4 @@ import { Result } from "@webiny/feature/api"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries/index.js"; import { ListFilesRepository as RepositoryAbstraction, @@ -13,21 +12,18 @@ import { EntryToFileMapper } from "../shared/EntryToFileMapper.js"; class ListFilesRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private listLatestEntries: ListLatestEntriesUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async execute( input: ListFilesInput ): Promise> { - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.listLatestEntries.execute(this.fileModel, { - where: input.where || {}, - limit: input.limit || 40, - after: input.after || undefined, - sort: input.sort || ["id_DESC"], - search: input.search - }); + const result = await this.listLatestEntries.execute(this.fileModel, { + where: input.where || {}, + limit: input.limit || 40, + after: input.after || undefined, + sort: input.sort || ["id_DESC"], + search: input.search }); if (result.isFail()) { @@ -47,5 +43,5 @@ class ListFilesRepositoryImpl implements RepositoryAbstraction.Interface { export const ListFilesRepository = RepositoryAbstraction.createImplementation({ implementation: ListFilesRepositoryImpl, - dependencies: [ListLatestEntriesUseCase, FileModel, IdentityContext] + dependencies: [ListLatestEntriesUseCase, FileModel] }); diff --git a/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts index af3143ed04f..cefe50d7112 100644 --- a/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts +++ b/packages/api-file-manager/src/features/file/ListTags/ListTagsRepository.ts @@ -1,6 +1,5 @@ import { Result } from "@webiny/feature/api"; import { GetUniqueFieldValuesUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetUniqueFieldValues"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { ListTagsRepository as RepositoryAbstraction, ListTagsInput, @@ -12,19 +11,16 @@ import { FilePersistenceError } from "~/domain/file/errors.js"; class ListTagsRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private getUniqueFieldValues: GetUniqueFieldValuesUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async execute(input: ListTagsInput): Promise> { - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.getUniqueFieldValues.execute(this.fileModel, { - fieldId: "tags", - where: { - ...(input.where || {}), - latest: true - } - }); + const result = await this.getUniqueFieldValues.execute(this.fileModel, { + fieldId: "tags", + where: { + ...(input.where || {}), + latest: true + } }); if (result.isFail()) { @@ -48,5 +44,5 @@ class ListTagsRepositoryImpl implements RepositoryAbstraction.Interface { export const ListTagsRepository = RepositoryAbstraction.createImplementation({ implementation: ListTagsRepositoryImpl, - dependencies: [GetUniqueFieldValuesUseCase, FileModel, IdentityContext] + dependencies: [GetUniqueFieldValuesUseCase, FileModel] }); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts index 147526808ce..c66db422653 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileRepository.ts @@ -1,17 +1,19 @@ import { Result } from "@webiny/feature/api"; import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; -import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; import { UpdateFileRepository as RepositoryAbstraction } from "./abstractions.js"; import { FileModel } from "~/domain/file/abstractions.js"; import type { File } from "~/domain/file/types.js"; -import { FileNotFoundError, FilePersistenceError } from "~/domain/file/errors.js"; +import { + FileNotAuthorizedError, + FileNotFoundError, + FilePersistenceError +} from "~/domain/file/errors.js"; import { FileToEntryMapper } from "../shared/FileToEntryMapper.js"; class UpdateFileRepositoryImpl implements RepositoryAbstraction.Interface { constructor( private updateEntry: UpdateEntryUseCase.Interface, - private fileModel: FileModel.Interface, - private identityContext: IdentityContext.Interface + private fileModel: FileModel.Interface ) {} async update(file: File): Promise> { @@ -25,15 +27,18 @@ class UpdateFileRepositoryImpl implements RepositoryAbstraction.Interface { // Files are not versioned, so we're always updating the same revision const id = `${file.id}#0001`; - const result = await this.identityContext.withoutAuthorization(async () => { - return await this.updateEntry.execute(this.fileModel, id, valuesToUpdate); - }); + const result = await this.updateEntry.execute(this.fileModel, id, valuesToUpdate); if (result.isFail()) { const error = result.error; if (error.code === "Cms/Entry/NotFound") { return Result.fail(new FileNotFoundError(id)); } + + if (error.code === "Cms/Entry/NotAuthorized") { + return Result.fail(new FileNotAuthorizedError()); + } + return Result.fail(new FilePersistenceError(result.error)); } @@ -43,5 +48,5 @@ class UpdateFileRepositoryImpl implements RepositoryAbstraction.Interface { export const UpdateFileRepository = RepositoryAbstraction.createImplementation({ implementation: UpdateFileRepositoryImpl, - dependencies: [UpdateEntryUseCase, FileModel, IdentityContext] + dependencies: [UpdateEntryUseCase, FileModel] }); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts index 119a48832d3..2fb0e6c495f 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/UpdateFileUseCase.ts @@ -30,9 +30,7 @@ class UpdateFileUseCaseImpl implements UseCaseAbstraction.Interface { } // Get the original file (includes ownership check) - const getResult = await this.identityContext.withoutAuthorization(() => { - return this.getFile.execute(input.id); - }); + const getResult = await this.getFile.execute(input.id); if (getResult.isFail()) { return Result.fail(getResult.error); diff --git a/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts index 1f36f3c2bbb..66a1f52d353 100644 --- a/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts +++ b/packages/api-file-manager/src/features/file/UpdateFile/abstractions.ts @@ -31,6 +31,7 @@ export interface IUpdateFileRepository { export interface IUpdateFileRepositoryErrors { notFound: FileNotFoundError; + notAuthorized: FileNotAuthorizedError; persistence: FilePersistenceError; } diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts index cc870b0ca85..4493a3bfa3e 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataFactories/createEntryData.ts @@ -168,7 +168,8 @@ export const createEntryData = async ({ locked, values: input, location: { - folderId: rawInput.wbyAco_location?.folderId || ROOT_FOLDER + folderId: + rawInput.location?.folderId || rawInput.wbyAco_location?.folderId || ROOT_FOLDER }, state: getState({ input: rawInput From 98a97dd5979013237ca8fe0678a9b0cecfb41186 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 3 Dec 2025 12:57:30 +0100 Subject: [PATCH 69/71] fix: update api-aco --- packages/api-aco/__tests__/flp.cms.test.ts | 47 ++--- packages/api-aco/__tests__/flp.fm.test.ts | 23 +-- packages/api-aco/__tests__/flp.tasks.test.ts | 65 ++++-- .../__tests__/folder.extensions.test.ts | 3 +- .../api-aco/__tests__/folder.flp.crud.test.ts | 27 +-- .../__tests__/folder.flp.security.test.ts | 8 +- packages/api-aco/__tests__/folder.so.test.ts | 75 +++---- .../__tests__/utils/expectNotAuthorized.ts | 14 ++ packages/api-aco/src/createAcoContext.ts | 27 ++- packages/api-aco/src/createAcoGraphQL.ts | 2 +- packages/api-aco/src/createAcoModels.ts | 3 +- .../api-aco/src/createAcoStorageOperations.ts | 2 - .../api-aco/src/domain/folder/abstractions.ts | 12 ++ packages/api-aco/src/domain/folder/errors.ts | 40 ++++ .../src/{ => domain}/folder/folder.model.ts | 0 .../flp/UpdateFlp/UpdateFlpUseCase.ts | 21 +- .../CreateFolder/CreateFolderRepository.ts | 127 ++++++++++++ .../CreateFolder/CreateFolderUseCase.ts | 29 +-- .../folders/CreateFolder/abstractions.ts | 44 +++- .../CreateFolderWithFolderLevelPermissions.ts | 20 +- .../features/folders/CreateFolder/feature.ts | 2 + .../DeleteFolder/DeleteFolderRepository.ts | 37 ++++ .../DeleteFolder/DeleteFolderUseCase.ts | 43 +++- .../folders/DeleteFolder/abstractions.ts | 45 +++- .../DeleteFolderWithFolderLevelPermissions.ts | 12 +- .../features/folders/DeleteFolder/feature.ts | 2 + .../GetAncestors/GetAncestorsRepository.ts | 92 +++++++++ .../GetAncestors/GetAncestorsUseCase.ts | 80 +------ .../folders/GetAncestors/abstractions.ts | 40 +++- .../features/folders/GetAncestors/feature.ts | 2 + .../folders/GetFolder/GetFolderRepository.ts | 37 ++++ .../folders/GetFolder/GetFolderUseCase.ts | 39 +--- .../folders/GetFolder/abstractions.ts | 42 +++- .../GetFolderWithFolderLevelPermissions.ts | 23 ++- .../src/features/folders/GetFolder/events.ts | 21 -- .../src/features/folders/GetFolder/feature.ts | 2 + .../GetFolderHierarchyRepository.ts | 118 +++++++++++ .../GetFolderHierarchyUseCase.ts | 66 +----- .../GetFolderHierarchy/abstractions.ts | 47 ++++- ...lderHierarchyWithFolderLevelPermissions.ts | 22 +- .../folders/GetFolderHierarchy/feature.ts | 2 + ...istFolderLevelPermissionsTargetsUseCase.ts | 17 +- .../abstractions.ts | 9 +- .../ListFolders/ListFoldersRepository.ts | 53 +++++ .../folders/ListFolders/ListFoldersUseCase.ts | 23 ++- .../folders/ListFolders/abstractions.ts | 38 +++- .../ListFoldersWithFolderLevelPermissions.ts | 17 +- .../features/folders/ListFolders/feature.ts | 2 + .../UpdateFolder/UpdateFolderRepository.ts | 141 +++++++++++++ .../UpdateFolder/UpdateFolderUseCase.ts | 47 +++-- .../folders/UpdateFolder/abstractions.ts | 48 ++++- .../UpdateFolderWithFolderLevelPermissions.ts | 88 ++++---- .../features/folders/UpdateFolder/feature.ts | 2 + .../features/folders/shared/abstractions.ts | 7 - .../api-aco/src/flp/tasks/syncFlp.task.ts | 22 +- .../src/folder/createFolderModelModifier.ts | 2 +- .../api-aco/src/folder/ensureFolderIsEmpty.ts | 6 +- packages/api-aco/src/folder/folder.crud.ts | 74 ------- packages/api-aco/src/folder/folder.gql.ts | 83 ++++++-- packages/api-aco/src/folder/folder.so.ts | 195 ------------------ packages/api-aco/src/folder/folder.types.ts | 85 +------- packages/api-aco/src/index.ts | 3 +- packages/api-aco/src/types.ts | 3 - .../decorateIfModelAuthorizationEnabled.ts | 75 ------- .../CreateEntry/CreateEntryUseCase.ts | 5 +- 65 files changed, 1451 insertions(+), 957 deletions(-) create mode 100644 packages/api-aco/src/domain/folder/abstractions.ts rename packages/api-aco/src/{ => domain}/folder/folder.model.ts (100%) create mode 100644 packages/api-aco/src/features/folders/CreateFolder/CreateFolderRepository.ts create mode 100644 packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderRepository.ts create mode 100644 packages/api-aco/src/features/folders/GetAncestors/GetAncestorsRepository.ts create mode 100644 packages/api-aco/src/features/folders/GetFolder/GetFolderRepository.ts delete mode 100644 packages/api-aco/src/features/folders/GetFolder/events.ts create mode 100644 packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyRepository.ts create mode 100644 packages/api-aco/src/features/folders/ListFolders/ListFoldersRepository.ts create mode 100644 packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderRepository.ts delete mode 100644 packages/api-aco/src/folder/folder.crud.ts delete mode 100644 packages/api-aco/src/folder/folder.so.ts delete mode 100644 packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts diff --git a/packages/api-aco/__tests__/flp.cms.test.ts b/packages/api-aco/__tests__/flp.cms.test.ts index 66d6e9ab7e8..79c9fa46202 100644 --- a/packages/api-aco/__tests__/flp.cms.test.ts +++ b/packages/api-aco/__tests__/flp.cms.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { useGraphQlHandler } from "./utils/useGraphQlHandler"; -import { expectNotAuthorized } from "./utils/expectNotAuthorized"; +import { expectCmsNotAuthorized, expectNotAuthorized } from "./utils/expectNotAuthorized.js"; import { AuthenticatedIdentity } from "@webiny/api-core/features/security/IdentityContext/index.js"; const identityA = new AuthenticatedIdentity({ id: "1", type: "admin", displayName: "A" }); @@ -133,7 +133,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Getting content in the folder should be forbidden for identity C. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .getEntry(model, { revision: createdEntry.id }) .then(([response]) => { @@ -166,7 +166,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { }); // Creating content in the folder should be forbidden for identity C. - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .createEntry(model, { data: { @@ -184,7 +184,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Updating content in the folder should be forbidden for identity C. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .updateEntry(model, { revision: createdEntry.id, @@ -199,7 +199,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Deleting a file in the folder should be forbidden for identity C. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .deleteEntry(model, { revision: createdEntry.id }) .then(([response]) => { @@ -360,7 +360,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Getting content in the folder should be forbidden for identity B. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityB.cms .getEntry(model, { revision: createdEntry.id }) .then(([response]) => { @@ -393,7 +393,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { }); // Creating content in the folder should be forbidden for identity B. - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityB.cms .createEntry(model, { data: { @@ -411,7 +411,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Updating content in the folder should be forbidden for identity B. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityB.cms .updateEntry(model, { revision: createdEntry.id, @@ -426,7 +426,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { // Deleting a file in the folder should be forbidden for identity C. for (let i = 0; i < entries.length; i++) { const createdEntry = entries[i]; - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityB.cms .deleteEntry(model, { revision: createdEntry.id }) .then(([response]) => { @@ -518,7 +518,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { }); // Creating content in the folder should be forbidden for identity C. - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .createEntry(model, { data: { @@ -534,7 +534,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { ); // Updating content in the folder should be forbidden for identity C. - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .updateEntry(model, { revision: createdEntry.id, @@ -546,7 +546,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { ); // Deleting a file in the folder should be forbidden for identity C. - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .deleteEntry(model, { revision: createdEntry.id }) .then(([response]) => { @@ -554,7 +554,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { }) ); - await expectNotAuthorized( + await expectCmsNotAuthorized( gqlIdentityC.cms .deleteEntry(model, { revision: createdEntry.entryId }) .then(([response]) => { @@ -659,15 +659,9 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { ).resolves.toMatchObject({ data: null, error: { - code: "DELETE_FOLDER_WITH_CHILDREN", - data: { - folder: { - slug: "folder-a" - }, - hasFolders: true, - hasContent: false - }, - message: "Delete all child folders and entries before proceeding." + code: "Aco/Folder/NotEmpty", + data: null, + message: "Folder is not empty." } }); @@ -689,14 +683,7 @@ describe("Folder Level Permissions - CMS GraphQL API", () => { await expectNotAuthorized( gqlIdentityC.aco.deleteFolder({ id: folderA.id }).then(([response]) => { return response.data.aco.deleteFolder; - }), - { - folder: { id: folderA.id }, - - // There are no entries in the folder, but there is one invisible / inaccessible folder. - hasContent: false, - hasFolders: true - } + }) ); }); }); diff --git a/packages/api-aco/__tests__/flp.fm.test.ts b/packages/api-aco/__tests__/flp.fm.test.ts index dd291c2c1d2..292ce01229d 100644 --- a/packages/api-aco/__tests__/flp.fm.test.ts +++ b/packages/api-aco/__tests__/flp.fm.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest"; import { useGraphQlHandler } from "./utils/useGraphQlHandler"; -import { expectNotAuthorized, expectFileNotAuthorized } from "./utils/expectNotAuthorized"; +import { expectNotAuthorized, expectFileNotAuthorized } from "./utils/expectNotAuthorized.js"; import { mdbid } from "@webiny/utils"; import { AuthenticatedIdentity } from "@webiny/api-core/features/security/IdentityContext/index.js"; @@ -458,15 +458,9 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { ).resolves.toMatchObject({ data: null, error: { - code: "DELETE_FOLDER_WITH_CHILDREN", - data: { - folder: { - slug: "folder-a" - }, - hasFolders: true, - hasContent: false - }, - message: "Delete all child folders and entries before proceeding." + code: "Aco/Folder/NotEmpty", + data: null, + message: "Folder is not empty." } }); @@ -488,14 +482,7 @@ describe("Folder Level Permissions - File Manager GraphQL API", () => { await expectNotAuthorized( gqlIdentityC.aco.deleteFolder({ id: folderA.id }).then(([response]) => { return response.data.aco.deleteFolder; - }), - { - folder: { id: folderA.id }, - - // There are no entries in the folder, but there is one invisible / inaccessible folder. - hasContent: false, - hasFolders: true - } + }) ); } ); diff --git a/packages/api-aco/__tests__/flp.tasks.test.ts b/packages/api-aco/__tests__/flp.tasks.test.ts index 621493c1071..97673042809 100644 --- a/packages/api-aco/__tests__/flp.tasks.test.ts +++ b/packages/api-aco/__tests__/flp.tasks.test.ts @@ -4,6 +4,8 @@ import type { Folder } from "~/folder/folder.types"; import { ROOT_FOLDER } from "~/constants"; import { CreateFlpUseCase } from "~/features/flp/CreateFlp/index.js"; import { DeleteFlpUseCase } from "~/features/flp/DeleteFlp/index.js"; +import { CreateFolderUseCase } from "~/features/folders/CreateFolder/index.js"; +import { UpdateFolderUseCase } from "~/features/folders/UpdateFolder/index.js"; describe("FLP Tasks", () => { describe("Folder Level Permissions - CREATE FLP", () => { @@ -15,14 +17,16 @@ describe("FLP Tasks", () => { it("should create an FLP record without a parent folder", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) - const folder = await context.aco.folder.create({ + const result = await createFolder.execute({ title: "Folder 1", type: "type1", slug: "folder1", parentId: null }); + const folder = result.value; const flp = await context.aco.flp.get(folder.id); expect(flp).toMatchObject({ @@ -37,15 +41,18 @@ describe("FLP Tasks", () => { it("should create an FLP record with a parent folder", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) + const updateFolder = context.container.resolve(UpdateFolderUseCase) - const folder1 = await context.aco.folder.create({ + const result1 = await createFolder.execute({ title: "Folder 1", type: "type1", slug: "folder1", parentId: null }); - await context.aco.folder.update(folder1.id, { + const folder1 = result1.value; + await updateFolder.execute(folder1.id, { permissions: [ { target: "admin:1234", @@ -54,13 +61,14 @@ describe("FLP Tasks", () => { ] }); - const folder2 = await context.aco.folder.create({ + const result2 = await createFolder.execute({ title: "Folder 2", type: "type1", slug: "folder2", parentId: folder1.id }); + const folder2 = result2.value; const flp = await context.aco.flp.get(folder2.id); expect(flp).toMatchObject({ @@ -90,8 +98,9 @@ describe("FLP Tasks", () => { it("should throw an error if the parent FLP is not found", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) - const parentFolder = { + const parentFolderInput = { title: "Parent Folder", type: "type1", slug: "parent-folder", @@ -99,19 +108,22 @@ describe("FLP Tasks", () => { }; // Let's create the parent folder first - const parentFolderResponse = await context.aco.folder.create(parentFolder); + const parentFolderResponse = await createFolder.execute(parentFolderInput); + const parentFolder = parentFolderResponse.value; // Let's delete the parent folder FLP record, this should not happen in real life. - await context.aco.flp.delete(parentFolderResponse.id); + await context.aco.flp.delete(parentFolder.id); const folder = { title: "Folder", type: "type1", slug: "folder-id", - parentId: parentFolderResponse.id + parentId: parentFolder.id }; - await expect(context.aco.folder.create(folder)).rejects.toThrow( + const createResult = await createFolder.execute(folder); + + expect(createResult.error.message).toEqual( "Parent folder level permission not found. Unable to create a new record in the FLP catalog." ); }); @@ -135,14 +147,17 @@ describe("FLP Tasks", () => { it("should delete an FLP record successfully", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) - const folder = await context.aco.folder.create({ + const result = await createFolder.execute({ title: "Folder 1", type: "type1", slug: "folder1", parentId: null }); + const folder = result.value; + const flp = await context.aco.flp.get(folder.id); expect(flp).toMatchObject({ @@ -172,15 +187,19 @@ describe("FLP Tasks", () => { it("should update a root folder's permissions", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) + const updateFolder = context.container.resolve(UpdateFolderUseCase) - const folder = await context.aco.folder.create({ + const result = await createFolder.execute({ type, title: "Main folder", slug: "main-folder", parentId: null }); - await context.aco.folder.update(folder.id, { + const folder = result.value; + + await updateFolder.execute(folder.id, { permissions: [ { target: "admin:1234", @@ -207,18 +226,24 @@ describe("FLP Tasks", () => { it("should update a folder's slug and path", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) + const updateFolder = context.container.resolve(UpdateFolderUseCase) - const folder = await context.aco.folder.create({ + const result = await createFolder.execute({ type, title: "Folder 1", slug: "folder-1", parentId: null }); - const updatedFolder = await context.aco.folder.update(folder.id, { + const folder = result.value; + + const updatedFolderResult = await updateFolder.execute(folder.id, { slug: "folder-1-updated" }); + const updatedFolder = updatedFolderResult.value; + const flp = await context.aco.flp.get(folder.id); expect(flp).toMatchObject({ id: folder.id, @@ -232,25 +257,31 @@ describe("FLP Tasks", () => { it("should update a folder's parent and path", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase) + const updateFolder = context.container.resolve(UpdateFolderUseCase) // Create parent folder - const parentFolder = await context.aco.folder.create({ + const parentFolderResult = await createFolder.execute({ type, title: "Parent folder", slug: "parent-folder", parentId: null }); + const parentFolder = parentFolderResult.value; + // Create child folder - const childFolder = await context.aco.folder.create({ + const childFolderResult = await createFolder.execute({ type, title: "Child folder", slug: "child-folder", parentId: null }); + const childFolder = childFolderResult.value; + // Update child folder to be under parent - await context.aco.folder.update(childFolder.id, { + await updateFolder.execute(childFolder.id, { parentId: parentFolder.id }); diff --git a/packages/api-aco/__tests__/folder.extensions.test.ts b/packages/api-aco/__tests__/folder.extensions.test.ts index 65db04bd61b..91766848d33 100644 --- a/packages/api-aco/__tests__/folder.extensions.test.ts +++ b/packages/api-aco/__tests__/folder.extensions.test.ts @@ -3,7 +3,8 @@ import { useGraphQlHandler } from "./utils/useGraphQlHandler"; import { createFolderModelModifier } from "~/folder/createFolderModelModifier"; import { folderMocks } from "~tests/mocks/folder.mock"; -describe("Folder Model Extensions", () => { +// TODO: revisit this once we revive extensions on CMS models +describe.skip("Folder Model Extensions", () => { const { aco } = useGraphQlHandler({ plugins: [ createFolderModelModifier(({ modifier }) => { diff --git a/packages/api-aco/__tests__/folder.flp.crud.test.ts b/packages/api-aco/__tests__/folder.flp.crud.test.ts index cf3f416be88..2284bae5b94 100644 --- a/packages/api-aco/__tests__/folder.flp.crud.test.ts +++ b/packages/api-aco/__tests__/folder.flp.crud.test.ts @@ -1,6 +1,6 @@ import { describe, it, test, expect } from "vitest"; import { useGraphQlHandler } from "./utils/useGraphQlHandler"; -import { expectNotAuthorized } from "./utils/expectNotAuthorized"; +import { expectNotAuthorized } from "./utils/expectNotAuthorized.js"; import { AuthenticatedIdentity } from "@webiny/api-core/features/IdentityContext"; const FOLDER_TYPE = "test-folders"; @@ -252,7 +252,7 @@ describe("Folder Level Permissions", () => { }) .then(([results]) => results.data.aco.updateFolder.error) ).resolves.toEqual({ - code: "", + code: "Aco/Folder/ValidationError", data: null, message: 'Permission target "my-target:xyz" is not valid.' }); @@ -280,7 +280,7 @@ describe("Folder Level Permissions", () => { }) .then(([results]) => results.data.aco.updateFolder.error) ).resolves.toEqual({ - code: "", + code: "Aco/Folder/ValidationError", data: null, message: 'Permission "inheritedFrom" cannot be set manually.' }); @@ -677,15 +677,9 @@ describe("Folder Level Permissions", () => { ).resolves.toMatchObject({ data: null, error: { - code: "DELETE_FOLDER_WITH_CHILDREN", - data: { - folder: { - slug: "folder-a" - }, - hasFolders: true, - hasContent: false - }, - message: "Delete all child folders and entries before proceeding." + code: "Aco/Folder/NotEmpty", + data: null, + message: "Folder is not empty." } }); @@ -707,14 +701,7 @@ describe("Folder Level Permissions", () => { await expectNotAuthorized( gqlIdentityC.aco.deleteFolder({ id: folderA.id }).then(([response]) => { return response.data.aco.deleteFolder; - }), - { - folder: { id: folderA.id }, - - // There are no entries in the folder, but there is one invisible / inaccessible folder. - hasContent: false, - hasFolders: true - } + }) ); }); }); diff --git a/packages/api-aco/__tests__/folder.flp.security.test.ts b/packages/api-aco/__tests__/folder.flp.security.test.ts index 7e5e8e344b8..727c0447215 100644 --- a/packages/api-aco/__tests__/folder.flp.security.test.ts +++ b/packages/api-aco/__tests__/folder.flp.security.test.ts @@ -1,6 +1,6 @@ import { describe, it, test, expect } from "vitest"; import { useGraphQlHandler } from "./utils/useGraphQlHandler"; -import { expectNotAuthorized } from "~tests/utils/expectNotAuthorized"; +import { expectNotAuthorized } from "~tests/utils/expectNotAuthorized.js"; import type { IdentityData } from "@webiny/api-core/features/IdentityContext"; const FOLDER_TYPE = "test-folders"; @@ -191,7 +191,7 @@ describe("Folder Level Permissions - Security Checks", () => { return response.data.aco.updateFolder.error; }) ).resolves.toEqual({ - code: "CANNOT_LOOSE_FOLDER_ACCESS", + code: "Aco/Folder/ValidationError", data: null, message: "Cannot continue because you would loose access to this folder." }); @@ -365,7 +365,7 @@ describe("Folder Level Permissions - Security Checks", () => { return response.data.aco.updateFolder.error; }) ).resolves.toEqual({ - code: "CANNOT_MOVE_FOLDER_TO_NEW_PARENT", + code: "Aco/Folder/CannotMoveToNewParent", data: null, message: "Cannot move folder to a new parent because you don't have access to the new parent." @@ -478,7 +478,7 @@ describe("Folder Level Permissions - Security Checks", () => { return response.data.aco.updateFolder.error; }) ).resolves.toEqual({ - code: "CANNOT_MOVE_FOLDER_TO_NEW_PARENT", + code: "Aco/Folder/CannotMoveToNewParent", data: null, message: "Cannot move folder to a new parent because you don't have access to the new parent." diff --git a/packages/api-aco/__tests__/folder.so.test.ts b/packages/api-aco/__tests__/folder.so.test.ts index 591761a453a..132be1c9698 100644 --- a/packages/api-aco/__tests__/folder.so.test.ts +++ b/packages/api-aco/__tests__/folder.so.test.ts @@ -228,8 +228,12 @@ describe("`folder` CRUD", () => { getFolder: { data: null, error: { - code: "Cms/Entry/NotFound", - data: null + code: "Aco/Folder/NotFound", + data: { + folder: { + id: expect.any(String) + } + } } } } @@ -734,8 +738,9 @@ describe("`folder` CRUD", () => { deleteFolder: { data: null, error: expect.objectContaining({ - code: "DELETE_FOLDER_WITH_CHILDREN", - message: "Delete all child folders and entries before proceeding." + code: "Aco/Folder/NotEmpty", + data: null, + message: "Folder is not empty." }) } } @@ -812,8 +817,9 @@ describe("`folder` CRUD", () => { deleteFolder: { data: null, error: expect.objectContaining({ - code: "DELETE_FOLDER_WITH_CHILDREN", - message: "Delete all child folders and entries before proceeding." + code: "Aco/Folder/NotEmpty", + data: null, + message: "Folder is not empty." }) } } @@ -871,14 +877,9 @@ describe("`folder` CRUD", () => { createFolder: { data: null, error: { - code: "FOLDER_ALREADY_EXISTS", + code: "Aco/Folder/ValidationError", message: `Folder with slug "${folderMocks.folderA.slug}" already exists at this level.`, - data: { - params: { - slug: "folder-a", - type: "page" - } - } + data: null } } } @@ -900,17 +901,9 @@ describe("`folder` CRUD", () => { createFolder: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Aco/Folder/ValidationError", message: "Validation failed.", - data: [ - { - error: "Value is required.", - fieldId: "title", - storageId: "text@title", - id: "title", - parents: [] - } - ] + data: null } } } @@ -932,17 +925,9 @@ describe("`folder` CRUD", () => { createFolder: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Aco/Folder/ValidationError", message: "Validation failed.", - data: [ - { - error: "Value is required.", - fieldId: "slug", - storageId: "text@slug", - id: "slug", - parents: [] - } - ] + data: null } } } @@ -964,17 +949,9 @@ describe("`folder` CRUD", () => { createFolder: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Aco/Folder/ValidationError", message: "Validation failed.", - data: [ - { - error: "Value must consist of only 'a-z', '0-9' and '-'.", - fieldId: "slug", - storageId: "text@slug", - id: "slug", - parents: [] - } - ] + data: null } } } @@ -1012,9 +989,13 @@ describe("`folder` CRUD", () => { expect(result.data.aco.updateFolder).toEqual({ data: null, error: { - code: "Cms/Entry/NotFound", - message: `Entry "${id}" was not found!`, - data: null + code: "Aco/Folder/NotFound", + message: `Folder not found!`, + data: { + folder: { + id: "any-id" + } + } } }); }); @@ -1042,7 +1023,7 @@ describe("`folder` CRUD", () => { updateFolder: { data: null, error: expect.objectContaining({ - code: "FOLDER_ALREADY_EXISTS", + code: "Aco/Folder/ValidationError", message: `Folder with slug "${folderMocks.folderA.slug}" already exists at this level.` }) } diff --git a/packages/api-aco/__tests__/utils/expectNotAuthorized.ts b/packages/api-aco/__tests__/utils/expectNotAuthorized.ts index 62e564bfece..c3d6298c3f8 100644 --- a/packages/api-aco/__tests__/utils/expectNotAuthorized.ts +++ b/packages/api-aco/__tests__/utils/expectNotAuthorized.ts @@ -14,6 +14,20 @@ export const expectNotAuthorized = async ( }); }; +export const expectCmsNotAuthorized = async ( + promise: Promise, + data: Record | null = null +) => { + await expect(promise).resolves.toMatchObject({ + data: null, + error: { + code: "Cms/Entry/NotAuthorized", + data, + message: "Not authorized!" + } + }); +}; + export const expectFileNotAuthorized = async ( promise: Promise, data: Record | null = null diff --git a/packages/api-aco/src/createAcoContext.ts b/packages/api-aco/src/createAcoContext.ts index 372dc2aa8c0..ca79f608c5d 100644 --- a/packages/api-aco/src/createAcoContext.ts +++ b/packages/api-aco/src/createAcoContext.ts @@ -3,8 +3,6 @@ import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createAcoStorageOperations } from "~/createAcoStorageOperations.js"; import type { AcoContext } from "~/types.js"; -import { createFolderCrudMethods } from "~/folder/folder.crud.js"; -import { CmsEntriesCrudDecorators } from "~/utils/decorators/CmsEntriesCrudDecorators.js"; import { createFilterCrudMethods } from "~/filter/filter.crud.js"; import { createFlpCrudMethods } from "~/flp/index.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; @@ -25,16 +23,17 @@ import { DeleteFlpFeature } from "~/features/flp/DeleteFlp/index.js"; import { UpdateFlpFeature } from "~/features/flp/UpdateFlp/index.js"; import { FolderLevelPermissionsFeature } from "~/features/flp/FolderLevelPermissions/index.js"; import { EnsureFolderIsEmptyOnDeleteFeature } from "~/features/folders/EnsureFolderIsEmptyOnDelete/index.js"; -import { - FilterStorageOperations, - FolderStorageOperations -} from "~/features/folders/shared/abstractions.js"; +import { FilterStorageOperations } from "~/features/folders/shared/abstractions.js"; import { ListFlpsFeature } from "~/features/flp/ListFlps/feature.js"; import { GetFlpFeature } from "~/features/flp/GetFlp/feature.js"; import { ListFolderLevelPermissionsTargetsFeature } from "~/features/folders/ListFolderLevelPermissionsTargets/feature.js"; import { Tenant } from "@webiny/api-core/types/tenancy"; import { getLocale } from "@webiny/api-core/legacy/i18n/getLocale.js"; import { CmsFlpFeature } from "~/features/cms/index.js"; +import { createFolderModel, FOLDER_MODEL_ID } from "~/domain/folder/folder.model.js"; +import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import { createModelPlugin } from "@webiny/api-headless-cms/plugins/index.js"; interface CreateAcoContextParams { useFolderLevelPermissions?: boolean; @@ -47,6 +46,16 @@ const setupAcoContext = async ( ): Promise => { const { tenancy, security } = context; + const folderModelDefinition = createFolderModel(); + context.plugins.register(createModelPlugin(folderModelDefinition)); + + const getModel = context.container.resolve(GetModelUseCase); + + await context.security.withoutAuthorization(async () => { + const folderModel = await getModel.execute(FOLDER_MODEL_ID); + context.container.registerInstance(FolderModel, folderModel.value); + }); + const getTenant = (): Tenant => { return tenancy.getCurrentTenant(); }; @@ -75,7 +84,6 @@ const setupAcoContext = async ( /** * Register legacy dependencies via abstractions */ - context.container.registerInstance(FolderStorageOperations, storageOperations.folder); context.container.registerInstance(FilterStorageOperations, storageOperations.filter); /** @@ -93,9 +101,7 @@ const setupAcoContext = async ( ListFolderLevelPermissionsTargetsFeature.register(context.container); - GetFolderHierarchyFeature.register(context.container, { - storageOperations: storageOperations.folder - }); + GetFolderHierarchyFeature.register(context.container); GetAncestorsFeature.register(context.container); @@ -135,7 +141,6 @@ const setupAcoContext = async ( const folderLevelPermissions = context.container.resolve(FolderLevelPermissions); context.aco = { - folder: createFolderCrudMethods({ container: context.container }), filter: createFilterCrudMethods({ container: context.container, getLocale, diff --git a/packages/api-aco/src/createAcoGraphQL.ts b/packages/api-aco/src/createAcoGraphQL.ts index dafef99f499..1d34e1ddf53 100644 --- a/packages/api-aco/src/createAcoGraphQL.ts +++ b/packages/api-aco/src/createAcoGraphQL.ts @@ -7,7 +7,7 @@ import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; import type { CmsModel } from "@webiny/api-headless-cms/types/index.js"; import { createFieldTypePluginRecords } from "@webiny/api-headless-cms/graphql/schema/createFieldTypePluginRecords.js"; import { createGraphQLSchemaPluginFromFieldPlugins } from "@webiny/api-headless-cms/utils/getSchemaFromFieldPlugins.js"; -import { FOLDER_MODEL_ID } from "~/folder/folder.model.js"; +import { FOLDER_MODEL_ID } from "~/domain/folder/folder.model.js"; const emptyResolver = () => ({}); diff --git a/packages/api-aco/src/createAcoModels.ts b/packages/api-aco/src/createAcoModels.ts index f1c49bc5449..e0d3e631a3e 100644 --- a/packages/api-aco/src/createAcoModels.ts +++ b/packages/api-aco/src/createAcoModels.ts @@ -1,9 +1,10 @@ import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; import { createFilterModel } from "~/filter/filter.model.js"; -import { createFolderModel } from "~/folder/folder.model.js"; import { modelFactory } from "~/utils/modelFactory.js"; import { FolderCmsModelModifierPlugin } from "~/folder/createFolderModelModifier.js"; +import { createFolderModel } from "~/domain/folder/folder.model.js"; +// TODO: revisit this when we get to model extensions export const createAcoModels = async (context: CmsContext) => { /** * Create CmsModel plugins. diff --git a/packages/api-aco/src/createAcoStorageOperations.ts b/packages/api-aco/src/createAcoStorageOperations.ts index af50b091df8..45fbad071ba 100644 --- a/packages/api-aco/src/createAcoStorageOperations.ts +++ b/packages/api-aco/src/createAcoStorageOperations.ts @@ -2,7 +2,6 @@ import type { CmsContext, HeadlessCms } from "@webiny/api-headless-cms/types/ind import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createFilterOperations } from "~/filter/filter.so.js"; -import { createFolderOperations } from "~/folder/folder.so.js"; import { createAcoModels } from "~/createAcoModels.js"; import type { AcoStorageOperations } from "~/types.js"; @@ -24,7 +23,6 @@ export const createAcoStorageOperations = async ( await createAcoModels(context); return { - folder: createFolderOperations(params), filter: createFilterOperations(params), flp: createFlpOperations(params) }; diff --git a/packages/api-aco/src/domain/folder/abstractions.ts b/packages/api-aco/src/domain/folder/abstractions.ts new file mode 100644 index 00000000000..64c02641bf9 --- /dev/null +++ b/packages/api-aco/src/domain/folder/abstractions.ts @@ -0,0 +1,12 @@ +import { createAbstraction } from "@webiny/feature/api"; +import type { CmsModel } from "@webiny/api-headless-cms/types"; + +/** + * FolderModel abstraction - represents the ACO folder CMS model. + * This will be registered via container.registerInstance in the composite feature. + */ +export const FolderModel = createAbstraction("FolderModel"); + +export namespace FolderModel { + export type Interface = CmsModel; +} diff --git a/packages/api-aco/src/domain/folder/errors.ts b/packages/api-aco/src/domain/folder/errors.ts index f1dbe83d120..24711f88fdc 100644 --- a/packages/api-aco/src/domain/folder/errors.ts +++ b/packages/api-aco/src/domain/folder/errors.ts @@ -24,3 +24,43 @@ export class FolderNotAuthorizedError extends BaseError { }); } } + +export class FolderPersistenceError extends BaseError { + override readonly code = "Aco/Folder/PersistenceError" as const; + + constructor(error: Error) { + super({ + message: error.message + }); + } +} + +export class FolderValidationError extends BaseError { + override readonly code = "Aco/Folder/ValidationError" as const; + + constructor(message: string) { + super({ + message + }); + } +} + +export class FolderCannotMoveToNewParent extends BaseError { + override readonly code = "Aco/Folder/CannotMoveToNewParent" as const; + + constructor() { + super({ + message: `Cannot move folder to a new parent because you don't have access to the new parent.` + }); + } +} + +export class FolderNotEmptyError extends BaseError { + override readonly code = "Aco/Folder/NotEmpty" as const; + + constructor() { + super({ + message: `Folder is not empty.` + }); + } +} diff --git a/packages/api-aco/src/folder/folder.model.ts b/packages/api-aco/src/domain/folder/folder.model.ts similarity index 100% rename from packages/api-aco/src/folder/folder.model.ts rename to packages/api-aco/src/domain/folder/folder.model.ts diff --git a/packages/api-aco/src/features/flp/UpdateFlp/UpdateFlpUseCase.ts b/packages/api-aco/src/features/flp/UpdateFlp/UpdateFlpUseCase.ts index 64e4b0f248d..948880326c5 100644 --- a/packages/api-aco/src/features/flp/UpdateFlp/UpdateFlpUseCase.ts +++ b/packages/api-aco/src/features/flp/UpdateFlp/UpdateFlpUseCase.ts @@ -3,7 +3,8 @@ import { Path } from "~/utils/Path.js"; import { Permissions, ROOT_FOLDER } from "@webiny/shared-aco"; import type { UpdateFlpUseCase as UseCaseAbstraction, UpdateFlpParams } from "./abstractions.js"; import type { AcoContext, Folder, FolderLevelPermission, FolderPermission } from "~/types.js"; -import { FOLDER_MODEL_ID } from "~/folder/folder.model.js"; +import { ListFoldersUseCase } from "~/features/folders/ListFolders/index.js"; +import { FolderModel } from "~/domain/folder/abstractions.js"; interface FlpUpdateData { parentId: string; @@ -143,7 +144,7 @@ export class UpdateFlpUseCase implements UseCaseAbstraction.Interface { await this.context.aco.flp.batchUpdate(items); // Update all folders with the new path - const folderModel = await this.getFolderModel(); + const folderModel = this.context.container.resolve(FolderModel); for (const item of items) { const { id, data } = item; // Directly update the folder in CMS storage to bypass any folder update event triggers. @@ -183,8 +184,10 @@ export class UpdateFlpUseCase implements UseCaseAbstraction.Interface { } private async listDirectChildren(flp: FolderLevelPermission): Promise { - const [folders] = await this.context.security.withoutAuthorization(() => { - return this.context.aco.folder.listAll({ + const listFolders = this.context.container.resolve(ListFoldersUseCase); + + const result = await this.context.security.withoutAuthorization(() => { + return listFolders.execute({ where: { type: flp.type, parentId: flp.id @@ -192,6 +195,12 @@ export class UpdateFlpUseCase implements UseCaseAbstraction.Interface { }); }); + if (result.isFail()) { + throw result.error; + } + + const [folders] = result.value; + return await Promise.all(folders.map(folder => this.getFlp(folder))); } @@ -219,8 +228,4 @@ export class UpdateFlpUseCase implements UseCaseAbstraction.Interface { return flp; } - - private async getFolderModel() { - return await this.context.cms.getModel(FOLDER_MODEL_ID); - } } diff --git a/packages/api-aco/src/features/folders/CreateFolder/CreateFolderRepository.ts b/packages/api-aco/src/features/folders/CreateFolder/CreateFolderRepository.ts new file mode 100644 index 00000000000..b002ec260b8 --- /dev/null +++ b/packages/api-aco/src/features/folders/CreateFolder/CreateFolderRepository.ts @@ -0,0 +1,127 @@ +import { Result } from "@webiny/feature/api"; +import { + CreateFolderRepository as RepositoryAbstraction, + type ICreateFolderRepository +} from "./abstractions.js"; +import { CreateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/CreateEntry"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { CreateFolderParams, Folder } from "~/folder/folder.types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderPersistenceError, FolderValidationError } from "~/domain/folder/errors.js"; +import { Path } from "~/utils/Path.js"; + +class CreateFolderRepositoryImpl implements ICreateFolderRepository { + constructor( + private createEntry: CreateEntryUseCase.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute(data: CreateFolderParams): Promise> { + // Check if folder already exists + const checkResult = await this.checkExistingFolder({ + type: data.type, + slug: data.slug, + parentId: data.parentId + }); + + if (checkResult.isFail()) { + return Result.fail(checkResult.error); + } + + // Create folder path + const pathResult = await this.createFolderPath({ + slug: data.slug, + parentId: data.parentId + }); + + if (pathResult.isFail()) { + return Result.fail(pathResult.error); + } + + // Create the entry + const result = await this.createEntry.execute(this.folderModel, { + ...data, + parentId: data.parentId || null, + path: pathResult.value + }); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/ValidationError") { + return Result.fail(new FolderValidationError(result.error.message)); + } + return Result.fail(new FolderPersistenceError(result.error)); + } + + const folder = EntryToFolderMapper.toFolder(result.value); + return Result.ok(folder); + } + + private async checkExistingFolder(params: { + type: string; + slug: string; + parentId?: string | null; + excludeId?: string; + }): Promise> { + const { type, slug, parentId, excludeId } = params; + + const result = await this.listLatestEntries.execute(this.folderModel, { + where: { + latest: true, + type, + slug, + parentId, + ...(excludeId ? { id_not: excludeId } : {}) + }, + limit: 1 + }); + + if (result.isFail()) { + return Result.fail(new FolderPersistenceError(result.error)); + } + + const [entries] = result.value; + + if (entries.length > 0) { + return Result.fail( + new FolderValidationError( + `Folder with slug "${slug}" already exists at this level.` + ) + ); + } + + return Result.ok(); + } + + private async createFolderPath(params: { + slug: string; + parentId?: string | null; + }): Promise> { + const { slug, parentId } = params; + + if (!parentId) { + return Result.ok(Path.create(slug)); + } + + const parentResult = await this.getEntryById.execute(this.folderModel, parentId); + + if (parentResult.isFail()) { + return Result.fail( + new FolderPersistenceError( + new Error("Parent folder not found. Unable to create the folder path") + ) + ); + } + + const parentFolder = EntryToFolderMapper.toFolder(parentResult.value); + return Result.ok(Path.create(slug, parentFolder.path)); + } +} + +export const CreateFolderRepository = RepositoryAbstraction.createImplementation({ + implementation: CreateFolderRepositoryImpl, + dependencies: [CreateEntryUseCase, ListLatestEntriesUseCase, GetEntryByIdUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/CreateFolder/CreateFolderUseCase.ts b/packages/api-aco/src/features/folders/CreateFolder/CreateFolderUseCase.ts index 59dd9de7361..75627b2cba4 100644 --- a/packages/api-aco/src/features/folders/CreateFolder/CreateFolderUseCase.ts +++ b/packages/api-aco/src/features/folders/CreateFolder/CreateFolderUseCase.ts @@ -1,24 +1,23 @@ +import { Result } from "@webiny/feature/api"; import { EventPublisher, EventPublisher as EventPublisherAbstraction } from "@webiny/api-core/features/EventPublisher"; import { createImplementation } from "@webiny/di"; -import { CreateFolderUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { + CreateFolderUseCase as UseCaseAbstraction, + CreateFolderRepository +} from "./abstractions.js"; import { FolderBeforeCreateEvent, FolderAfterCreateEvent } from "./events.js"; -import type { - Folder, - CreateFolderParams, - AcoFolderStorageOperations -} from "~/folder/folder.types.js"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; +import type { Folder, CreateFolderParams } from "~/folder/folder.types.js"; class CreateFolderUseCaseImpl implements UseCaseAbstraction.Interface { constructor( private eventPublisher: EventPublisherAbstraction.Interface, - private storageOperations: AcoFolderStorageOperations + private repository: CreateFolderRepository.Interface ) {} - async execute(params: CreateFolderParams): Promise { + async execute(params: CreateFolderParams): Promise> { // Publish before create event const beforeCreateEvent = new FolderBeforeCreateEvent({ input: params @@ -27,7 +26,13 @@ class CreateFolderUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(beforeCreateEvent); // Execute the create operation - const folder = await this.storageOperations.createFolder({ data: params }); + const result = await this.repository.execute(params); + + if (result.isFail()) { + return result; + } + + const folder = result.value; // Publish after create event const afterCreateEvent = new FolderAfterCreateEvent({ @@ -36,12 +41,12 @@ class CreateFolderUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(afterCreateEvent); - return folder; + return Result.ok(folder); } } export const CreateFolderUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: CreateFolderUseCaseImpl, - dependencies: [EventPublisher, FolderStorageOperations] + dependencies: [EventPublisher, CreateFolderRepository] }); diff --git a/packages/api-aco/src/features/folders/CreateFolder/abstractions.ts b/packages/api-aco/src/features/folders/CreateFolder/abstractions.ts index 99ad63d7290..5eacab363ae 100644 --- a/packages/api-aco/src/features/folders/CreateFolder/abstractions.ts +++ b/packages/api-aco/src/features/folders/CreateFolder/abstractions.ts @@ -1,16 +1,56 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/EventPublisher"; import type { Folder, CreateFolderParams } from "~/folder/folder.types.js"; +import type { + FolderNotAuthorizedError, + FolderPersistenceError, + FolderValidationError +} from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * CreateFolder repository interface + */ +export interface ICreateFolderRepository { + execute(data: CreateFolderParams): Promise>; +} + +export interface ICreateFolderRepositoryErrors { + validation: FolderValidationError; + persistence: FolderPersistenceError; +} + +type RepositoryError = ICreateFolderRepositoryErrors[keyof ICreateFolderRepositoryErrors]; + +export const CreateFolderRepository = + createAbstraction("CreateFolderRepository"); + +export namespace CreateFolderRepository { + export type Interface = ICreateFolderRepository; + export type Error = RepositoryError; +} + +/** + * CreateFolder use case interface + */ export interface ICreateFolderUseCase { - execute: (params: CreateFolderParams) => Promise; + execute(params: CreateFolderParams): Promise>; } +export interface ICreateFolderUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + persistence: FolderPersistenceError; + validation: FolderValidationError; +} + +type UseCaseError = ICreateFolderUseCaseErrors[keyof ICreateFolderUseCaseErrors]; + export const CreateFolderUseCase = createAbstraction("CreateFolderUseCase"); export namespace CreateFolderUseCase { export type Interface = ICreateFolderUseCase; + export type Return = Promise>; + export type Error = UseCaseError; } // Event Payload Types diff --git a/packages/api-aco/src/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.ts index a988977ca62..2896e972719 100644 --- a/packages/api-aco/src/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.ts @@ -1,8 +1,8 @@ import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import { CreateFolderUseCase } from "../abstractions.js"; import type { CreateFolderParams } from "~/folder/folder.types.js"; -import { createDecorator } from "@webiny/feature/api"; -import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; +import { createDecorator, Result } from "@webiny/feature/api"; +import { FolderNotAuthorizedError } from "~/domain/folder/errors.js"; class CreateFolderWithFolderLevelPermissionsImpl implements CreateFolderUseCase.Interface { private folderLevelPermissions: FolderLevelPermissions.Interface; @@ -16,7 +16,7 @@ class CreateFolderWithFolderLevelPermissionsImpl implements CreateFolderUseCase. this.decoretee = decoretee; } - async execute(params: CreateFolderParams) { + async execute(params: CreateFolderParams): CreateFolderUseCase.Return { let canCreateFolder: boolean; if (params.parentId) { const permissions = await this.folderLevelPermissions.getFolderLevelPermissions( @@ -31,20 +31,26 @@ class CreateFolderWithFolderLevelPermissionsImpl implements CreateFolderUseCase. } if (!canCreateFolder) { - throw new NotAuthorizedError(); + return Result.fail(new FolderNotAuthorizedError()); } - const folder = await this.decoretee.execute(params); + const result = await this.decoretee.execute(params); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const folder = result.value; // Let's set default permissions based on the current user. const permissionsWithDefaults = await this.folderLevelPermissions.getDefaultPermissions( folder?.permissions ?? [] ); - return { + return Result.ok({ ...folder, permissions: permissionsWithDefaults - }; + }); } } diff --git a/packages/api-aco/src/features/folders/CreateFolder/feature.ts b/packages/api-aco/src/features/folders/CreateFolder/feature.ts index ffb15fa8f46..a42f3433dcb 100644 --- a/packages/api-aco/src/features/folders/CreateFolder/feature.ts +++ b/packages/api-aco/src/features/folders/CreateFolder/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { CreateFolderRepository } from "./CreateFolderRepository.js"; import { CreateFolderUseCase } from "./CreateFolderUseCase.js"; import { CreateFolderWithFolderLevelPermissions } from "~/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.js"; export const CreateFolderFeature = createFeature({ name: "CreateFolder", register(container: Container) { + container.register(CreateFolderRepository).inSingletonScope(); container.register(CreateFolderUseCase); container.registerDecorator(CreateFolderWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderRepository.ts b/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderRepository.ts new file mode 100644 index 00000000000..9725c463951 --- /dev/null +++ b/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderRepository.ts @@ -0,0 +1,37 @@ +import { Result } from "@webiny/feature/api"; +import { + DeleteFolderRepository as RepositoryAbstraction, + type IDeleteFolderRepository +} from "./abstractions.js"; +import { DeleteEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/DeleteEntry"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { Folder } from "~/folder/folder.types.js"; +import { FolderNotAuthorizedError, FolderPersistenceError } from "~/domain/folder/errors.js"; + +class DeleteFolderRepositoryImpl implements IDeleteFolderRepository { + constructor( + private deleteEntry: DeleteEntryUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute(folder: Folder): Promise> { + const result = await this.deleteEntry.execute(this.folderModel, folder.id, { + permanently: true, + force: true + }); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotAuthorized") { + return Result.fail(new FolderNotAuthorizedError()); + } + return Result.fail(new FolderPersistenceError(result.error)); + } + + return Result.ok(); + } +} + +export const DeleteFolderRepository = RepositoryAbstraction.createImplementation({ + implementation: DeleteFolderRepositoryImpl, + dependencies: [DeleteEntryUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderUseCase.ts b/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderUseCase.ts index 3d589d70d54..e0427883c55 100644 --- a/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderUseCase.ts +++ b/packages/api-aco/src/features/folders/DeleteFolder/DeleteFolderUseCase.ts @@ -1,32 +1,55 @@ +import { Result } from "@webiny/feature/api"; import { EventPublisher, EventPublisher as EventPublisherAbstraction } from "@webiny/api-core/features/EventPublisher"; -import { DeleteFolderUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { + DeleteFolderUseCase as UseCaseAbstraction, + DeleteFolderRepository +} from "./abstractions.js"; +import { GetFolderRepository } from "../GetFolder/abstractions.js"; import { FolderBeforeDeleteEvent, FolderAfterDeleteEvent } from "./events.js"; -import type { DeleteFolderParams, AcoFolderStorageOperations } from "~/folder/folder.types.js"; +import type { DeleteFolderParams } from "~/folder/folder.types.js"; import { createImplementation } from "@webiny/di"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; +import { FolderNotAuthorizedError, FolderNotEmptyError } from "~/domain/folder/errors.js"; class DeleteFolderUseCaseImpl implements UseCaseAbstraction.Interface { constructor( private eventPublisher: EventPublisherAbstraction.Interface, - private storageOperations: AcoFolderStorageOperations + private getFolderRepository: GetFolderRepository.Interface, + private repository: DeleteFolderRepository.Interface ) {} - async execute(params: DeleteFolderParams): Promise { + async execute(params: DeleteFolderParams): Promise> { // Get the folder before deletion - const folder = await this.storageOperations.getFolder({ id: params.id }); + const getFolderResult = await this.getFolderRepository.execute(params.id); + + if (getFolderResult.isFail()) { + return Result.fail(getFolderResult.error); + } + + const folder = getFolderResult.value; // Publish before delete event const beforeDeleteEvent = new FolderBeforeDeleteEvent({ folder }); - await this.eventPublisher.publish(beforeDeleteEvent); + try { + await this.eventPublisher.publish(beforeDeleteEvent); + } catch (err) { + if (err.code === "DELETE_FOLDER_WITH_CHILDREN") { + return Result.fail(new FolderNotEmptyError()); + } + return Result.fail(new FolderNotAuthorizedError()); + } // Execute the delete operation - await this.storageOperations.deleteFolder(params); + const result = await this.repository.execute(folder); + + if (result.isFail()) { + return result; + } // Publish after delete event const afterDeleteEvent = new FolderAfterDeleteEvent({ @@ -35,12 +58,12 @@ class DeleteFolderUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(afterDeleteEvent); - return true; + return Result.ok(); } } export const DeleteFolderUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: DeleteFolderUseCaseImpl, - dependencies: [EventPublisher, FolderStorageOperations] + dependencies: [EventPublisher, GetFolderRepository, DeleteFolderRepository] }); diff --git a/packages/api-aco/src/features/folders/DeleteFolder/abstractions.ts b/packages/api-aco/src/features/folders/DeleteFolder/abstractions.ts index 6c5e2b654db..c944e6c4a59 100644 --- a/packages/api-aco/src/features/folders/DeleteFolder/abstractions.ts +++ b/packages/api-aco/src/features/folders/DeleteFolder/abstractions.ts @@ -1,16 +1,57 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/EventPublisher"; import type { Folder, DeleteFolderParams } from "~/folder/folder.types.js"; +import { + type FolderNotAuthorizedError, + FolderNotEmptyError, + type FolderNotFoundError, + type FolderPersistenceError +} from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * DeleteFolder repository interface + */ +export interface IDeleteFolderRepository { + execute(folder: Folder): Promise>; +} + +export interface IDeleteFolderRepositoryErrors { + notAuthorized: FolderNotAuthorizedError; + persistence: FolderPersistenceError; +} + +type RepositoryError = IDeleteFolderRepositoryErrors[keyof IDeleteFolderRepositoryErrors]; + +export const DeleteFolderRepository = + createAbstraction("DeleteFolderRepository"); + +export namespace DeleteFolderRepository { + export type Interface = IDeleteFolderRepository; + export type Error = RepositoryError; +} + +/** + * DeleteFolder use case interface + */ export interface IDeleteFolderUseCase { - execute: (params: DeleteFolderParams) => Promise; + execute(params: DeleteFolderParams): Promise>; } +export interface IDeleteFolderUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + notFound: FolderNotFoundError; + notEmpty: FolderNotEmptyError; + persistence: FolderPersistenceError; +} + +type UseCaseError = IDeleteFolderUseCaseErrors[keyof IDeleteFolderUseCaseErrors]; + export const DeleteFolderUseCase = createAbstraction("DeleteFolderUseCase"); export namespace DeleteFolderUseCase { export type Interface = IDeleteFolderUseCase; + export type Error = UseCaseError; } // Event Payload Types diff --git a/packages/api-aco/src/features/folders/DeleteFolder/decorators/DeleteFolderWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/DeleteFolder/decorators/DeleteFolderWithFolderLevelPermissions.ts index dc939d12b25..c16f2e09438 100644 --- a/packages/api-aco/src/features/folders/DeleteFolder/decorators/DeleteFolderWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/DeleteFolder/decorators/DeleteFolderWithFolderLevelPermissions.ts @@ -1,7 +1,7 @@ import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import { DeleteFolderUseCase } from "../abstractions.js"; import type { DeleteFolderParams } from "~/folder/folder.types.js"; -import { createDecorator } from "@webiny/feature/api"; +import { createDecorator, Result } from "@webiny/feature/api"; class DeleteFolderWithFolderLevelPermissionsImpl implements DeleteFolderUseCase.Interface { private folderLevelPermissions: FolderLevelPermissions.Interface; @@ -21,8 +21,14 @@ class DeleteFolderWithFolderLevelPermissionsImpl implements DeleteFolderUseCase. permissions, rwd: "d" }); - await this.decoretee.execute(params); - return true; + + const result = await this.decoretee.execute(params); + + if (result.isFail()) { + return Result.fail(result.error); + } + + return Result.ok(); } } diff --git a/packages/api-aco/src/features/folders/DeleteFolder/feature.ts b/packages/api-aco/src/features/folders/DeleteFolder/feature.ts index d4da6eef242..e536eeda830 100644 --- a/packages/api-aco/src/features/folders/DeleteFolder/feature.ts +++ b/packages/api-aco/src/features/folders/DeleteFolder/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { DeleteFolderRepository } from "./DeleteFolderRepository.js"; import { DeleteFolderUseCase } from "./DeleteFolderUseCase.js"; import { DeleteFolderWithFolderLevelPermissions } from "./decorators/DeleteFolderWithFolderLevelPermissions.js"; export const DeleteFolderFeature = createFeature({ name: "DeleteFolder", register(container: Container) { + container.register(DeleteFolderRepository).inSingletonScope(); container.register(DeleteFolderUseCase); container.registerDecorator(DeleteFolderWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsRepository.ts b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsRepository.ts new file mode 100644 index 00000000000..21c207b6bb1 --- /dev/null +++ b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsRepository.ts @@ -0,0 +1,92 @@ +import { Result } from "@webiny/feature/api"; +import { + GetAncestorsRepository as RepositoryAbstraction, + type IGetAncestorsRepository, + type GetAncestorsParams +} from "./abstractions.js"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { Folder } from "~/folder/folder.types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderPersistenceError } from "~/domain/folder/errors.js"; +import { ROOT_FOLDER } from "~/constants.js"; + +class GetAncestorsRepositoryImpl implements IGetAncestorsRepository { + constructor( + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute(params: GetAncestorsParams): Promise> { + const { folder } = params; + + // No folder found: return an empty array + if (!folder) { + return Result.ok([]); + } + + // The folder has no parent (it's at root level): return an array with the folder + if (!folder.parentId) { + return Result.ok([folder]); + } + + // Construct paths for all ancestors of the folder + const parts = folder.path.split("/").slice(1); + const paths = parts.map((_, index) => { + return [ROOT_FOLDER, ...parts.slice(0, index + 1)].join("/"); + }); + + // Retrieve all folders that match the specified type and any of the constructed paths + const result = await this.listLatestEntries.execute(this.folderModel, { + where: { + type: folder.type, + path_in: paths + } + }); + + if (result.isFail()) { + return Result.fail(new FolderPersistenceError(result.error)); + } + + const [entries] = result.value; + const folders = entries.map(entry => EntryToFolderMapper.toFolder(entry)); + + // Create a Map with folders, using folder.id as key + const folderMap = new Map(); + folders.forEach(f => folderMap.set(f.id, f)); + + const findParents = (next: Folder[], current: Folder): Folder[] => { + // No folder found: return the result + if (!current) { + return next; + } + + // Push the current folder into the accumulator array + next.push(current); + + // No parentId found: return the result + if (!current.parentId) { + return next; + } + + const parent = folderMap.get(current.parentId); + + // No parent found: return the result + if (!parent) { + return next; + } + + // Go ahead and find parent for the current parent + return findParents(next, parent); + }; + + // Recursively find parents for a given folder id + const ancestors = findParents([], folder); + return Result.ok(ancestors); + } +} + +export const GetAncestorsRepository = RepositoryAbstraction.createImplementation({ + implementation: GetAncestorsRepositoryImpl, + dependencies: [ListLatestEntriesUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts index 3c6a5c76a3f..d8a206d6989 100644 --- a/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts +++ b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts @@ -1,82 +1,22 @@ -import type { GetAncestorsParams } from "./abstractions.js"; -import { GetAncestorsUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { Result } from "@webiny/feature/api"; +import { + GetAncestorsUseCase as UseCaseAbstraction, + GetAncestorsRepository, + type GetAncestorsParams +} from "./abstractions.js"; import type { Folder } from "~/folder/folder.types.js"; -import { ROOT_FOLDER } from "~/constants.js"; import { createImplementation } from "@webiny/di"; -import { ListFoldersUseCase } from "~/features/folders/ListFolders/index.js"; class GetAncestorsUseCaseImpl implements UseCaseAbstraction.Interface { - constructor(private listFoldersUseCase: ListFoldersUseCase.Interface) {} + constructor(private repository: GetAncestorsRepository.Interface) {} - public async execute(params: GetAncestorsParams): Promise { - const { folder } = params; - - const folders = await this.listFolders(folder); - - // Create a Map with folders, using folder.id as key - const folderMap = new Map(); - folders.forEach(folder => folderMap.set(folder.id, folder)); - - const findParents = (next: Folder[], current: Folder): Folder[] => { - // No folder found: return the result - if (!current) { - return next; - } - - // Push the current folder into the accumulator array - next.push(current); - - // No parentId found: return the result - if (!current.parentId) { - return next; - } - - const parent = folderMap.get(current.parentId); - - // No parent found: return the result - if (!parent) { - return next; - } - - // Go ahead and find parent for the current parent - return findParents(next, parent); - }; - - // No folder found: return an empty array - if (!folder) { - return []; - } - - // The folder has no parent (it's at root level): return an array with the folder - if (!folder.parentId) { - return [folder]; - } - - // Recursively find parents for a given folder id - return findParents([], folder); - } - - private async listFolders(folder: Folder): Promise { - // Construct paths for all ancestors of the folder - const parts = folder.path.split("/").slice(1); - const paths = parts.map((_, index) => { - return [ROOT_FOLDER, ...parts.slice(0, index + 1)].join("/"); - }); - - // Retrieve all folders that match the specified type and any of the constructed paths - const [folders] = await this.listFoldersUseCase.execute({ - where: { - type: folder.type, - path_in: paths - } - }); - - return folders; + public async execute(params: GetAncestorsParams): Promise> { + return await this.repository.execute(params); } } export const GetAncestorsUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: GetAncestorsUseCaseImpl, - dependencies: [ListFoldersUseCase] + dependencies: [GetAncestorsRepository] }); diff --git a/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts b/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts index 0bb48bf4b44..6eae2a71582 100644 --- a/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts +++ b/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts @@ -1,17 +1,53 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { Folder } from "~/folder/folder.types.js"; +import type { + FolderNotAuthorizedError, + FolderPersistenceError +} from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * GetAncestors repository interface + */ +export interface IGetAncestorsRepository { + execute(params: GetAncestorsParams): Promise>; +} + +export interface IGetAncestorsRepositoryErrors { + persistence: FolderPersistenceError; +} + +type RepositoryError = IGetAncestorsRepositoryErrors[keyof IGetAncestorsRepositoryErrors]; + +export const GetAncestorsRepository = + createAbstraction("GetAncestorsRepository"); + +export namespace GetAncestorsRepository { + export type Interface = IGetAncestorsRepository; + export type Error = RepositoryError; +} + +/** + * GetAncestors use case interface + */ export interface GetAncestorsParams { folder: Folder; } export interface IGetAncestorsUseCase { - execute: (params: GetAncestorsParams) => Promise; + execute(params: GetAncestorsParams): Promise>; } +export interface IGetAncestorsUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + persistence: FolderPersistenceError; +} + +type UseCaseError = IGetAncestorsUseCaseErrors[keyof IGetAncestorsUseCaseErrors]; + export const GetAncestorsUseCase = createAbstraction("GetAncestorsUseCase"); export namespace GetAncestorsUseCase { export type Interface = IGetAncestorsUseCase; + export type Error = UseCaseError; } diff --git a/packages/api-aco/src/features/folders/GetAncestors/feature.ts b/packages/api-aco/src/features/folders/GetAncestors/feature.ts index 836e6194a42..560209ddcb9 100644 --- a/packages/api-aco/src/features/folders/GetAncestors/feature.ts +++ b/packages/api-aco/src/features/folders/GetAncestors/feature.ts @@ -1,10 +1,12 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { GetAncestorsRepository } from "./GetAncestorsRepository.js"; import { GetAncestorsUseCase } from "./GetAncestorsUseCase.js"; export const GetAncestorsFeature = createFeature({ name: "GetAncestors", register(container: Container) { + container.register(GetAncestorsRepository).inSingletonScope(); container.register(GetAncestorsUseCase); } }); diff --git a/packages/api-aco/src/features/folders/GetFolder/GetFolderRepository.ts b/packages/api-aco/src/features/folders/GetFolder/GetFolderRepository.ts new file mode 100644 index 00000000000..f885625db79 --- /dev/null +++ b/packages/api-aco/src/features/folders/GetFolder/GetFolderRepository.ts @@ -0,0 +1,37 @@ +import { Result } from "@webiny/feature/api"; +import { + GetFolderRepository as RepositoryAbstraction, + type IGetFolderRepository +} from "./abstractions.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { Folder } from "~/folder/folder.types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderNotFoundError, FolderPersistenceError } from "~/domain/folder/errors.js"; + +class GetFolderRepositoryImpl implements IGetFolderRepository { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute(id: string): Promise> { + const result = await this.getEntryById.execute(this.folderModel, id); + + if (result.isFail()) { + if (result.error.code === "Cms/Entry/NotFound") { + return Result.fail(new FolderNotFoundError(id)); + } + + return Result.fail(new FolderPersistenceError(result.error)); + } + + const folder = EntryToFolderMapper.toFolder(result.value); + return Result.ok(folder); + } +} + +export const GetFolderRepository = RepositoryAbstraction.createImplementation({ + implementation: GetFolderRepositoryImpl, + dependencies: [GetEntryByIdUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/GetFolder/GetFolderUseCase.ts b/packages/api-aco/src/features/folders/GetFolder/GetFolderUseCase.ts index 070e1bcac6e..c7da3733ab3 100644 --- a/packages/api-aco/src/features/folders/GetFolder/GetFolderUseCase.ts +++ b/packages/api-aco/src/features/folders/GetFolder/GetFolderUseCase.ts @@ -1,43 +1,18 @@ -import { - EventPublisher, - EventPublisher as EventPublisherAbstraction -} from "@webiny/api-core/features/EventPublisher"; +import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; -import { GetFolderUseCase as UseCaseAbstraction } from "./abstractions.js"; -import { FolderBeforeGetEvent, FolderAfterGetEvent } from "./events.js"; -import type { Folder, GetFolderParams, AcoFolderStorageOperations } from "~/folder/folder.types.js"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; +import { GetFolderUseCase as UseCaseAbstraction, GetFolderRepository } from "./abstractions.js"; +import type { Folder } from "~/folder/folder.types.js"; class GetFolderUseCaseImpl implements UseCaseAbstraction.Interface { - constructor( - private eventPublisher: EventPublisherAbstraction.Interface, - private storageOperations: AcoFolderStorageOperations - ) {} + constructor(private repository: GetFolderRepository.Interface) {} - async execute(params: GetFolderParams): Promise { - // Publish before get event - const beforeGetEvent = new FolderBeforeGetEvent({ - params - }); - - await this.eventPublisher.publish(beforeGetEvent); - - // Execute the get operation - const folder = await this.storageOperations.getFolder(params); - - // Publish after get event - const afterGetEvent = new FolderAfterGetEvent({ - folder - }); - - await this.eventPublisher.publish(afterGetEvent); - - return folder; + async execute(id: string): Promise> { + return this.repository.execute(id); } } export const GetFolderUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: GetFolderUseCaseImpl, - dependencies: [EventPublisher, FolderStorageOperations] + dependencies: [GetFolderRepository] }); diff --git a/packages/api-aco/src/features/folders/GetFolder/abstractions.ts b/packages/api-aco/src/features/folders/GetFolder/abstractions.ts index 87f9646a71c..2f694a24b85 100644 --- a/packages/api-aco/src/features/folders/GetFolder/abstractions.ts +++ b/packages/api-aco/src/features/folders/GetFolder/abstractions.ts @@ -1,16 +1,54 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import { DomainEvent, IEventHandler } from "@webiny/api-core/features/EventPublisher"; import type { Folder, GetFolderParams } from "~/folder/folder.types.js"; +import type { + FolderNotAuthorizedError, + FolderNotFoundError, + FolderPersistenceError +} from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * GetFolder repository interface + */ +export interface IGetFolderRepository { + execute(id: string): Promise>; +} + +export interface IGetFolderRepositoryErrors { + notFound: FolderNotFoundError; + persistence: FolderPersistenceError; +} + +type RepositoryError = IGetFolderRepositoryErrors[keyof IGetFolderRepositoryErrors]; + +export const GetFolderRepository = createAbstraction("GetFolderRepository"); + +export namespace GetFolderRepository { + export type Interface = IGetFolderRepository; + export type Error = RepositoryError; +} + +/** + * GetFolder use case interface + */ export interface IGetFolderUseCase { - execute: (params: GetFolderParams) => Promise; + execute(id: string): Promise>; } +export interface IGetFolderUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + notFound: FolderNotFoundError; + persistence: FolderPersistenceError; +} + +type UseCaseError = IGetFolderUseCaseErrors[keyof IGetFolderUseCaseErrors]; + export const GetFolderUseCase = createAbstraction("GetFolderUseCase"); export namespace GetFolderUseCase { export type Interface = IGetFolderUseCase; + export type Error = UseCaseError; } // Event Payload Types diff --git a/packages/api-aco/src/features/folders/GetFolder/decorators/GetFolderWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/GetFolder/decorators/GetFolderWithFolderLevelPermissions.ts index e7974144f9a..997cb90bf30 100644 --- a/packages/api-aco/src/features/folders/GetFolder/decorators/GetFolderWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/GetFolder/decorators/GetFolderWithFolderLevelPermissions.ts @@ -1,8 +1,7 @@ import { GetFolderUseCase } from "../abstractions.js"; -import type { GetFolderParams } from "~/folder/folder.types.js"; -import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; -import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js"; +import { FolderNotAuthorizedError } from "~/domain/folder/errors.js"; class GetFolderWithFolderLevelPermissionsImpl implements GetFolderUseCase.Interface { private folderLevelPermissions: FolderLevelPermissions.Interface; @@ -16,8 +15,13 @@ class GetFolderWithFolderLevelPermissionsImpl implements GetFolderUseCase.Interf this.decoretee = decoretee; } - async execute(params: GetFolderParams) { - const folder = await this.decoretee.execute(params); + async execute(id: string) { + const result = await this.decoretee.execute(id); + if (result.isFail()) { + return Result.fail(result.error); + } + + const folder = result.value; const permissions = await this.folderLevelPermissions.getFolderLevelPermissions(folder.id); // Let's check if the current user has read access level. @@ -27,18 +31,17 @@ class GetFolderWithFolderLevelPermissionsImpl implements GetFolderUseCase.Interf }); if (!canAccessFolder) { - throw new NotAuthorizedError(); + return Result.fail(new FolderNotAuthorizedError()); } - return { + return Result.ok({ ...folder, permissions - }; + }); } } -export const GetFolderWithFolderLevelPermissions = createDecorator({ - abstraction: GetFolderUseCase, +export const GetFolderWithFolderLevelPermissions = GetFolderUseCase.createDecorator({ decorator: GetFolderWithFolderLevelPermissionsImpl, dependencies: [FolderLevelPermissions] }); diff --git a/packages/api-aco/src/features/folders/GetFolder/events.ts b/packages/api-aco/src/features/folders/GetFolder/events.ts deleted file mode 100644 index b321afb7e05..00000000000 --- a/packages/api-aco/src/features/folders/GetFolder/events.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { DomainEvent } from "@webiny/api-core/features/EventPublisher"; -import { FolderBeforeGetHandler, FolderAfterGetHandler } from "./abstractions.js"; -import type { FolderBeforeGetPayload, FolderAfterGetPayload } from "./abstractions.js"; - -// FolderBeforeGet Event -export class FolderBeforeGetEvent extends DomainEvent { - eventType = "folder.beforeGet" as const; - - getHandlerAbstraction() { - return FolderBeforeGetHandler; - } -} - -// FolderAfterGet Event -export class FolderAfterGetEvent extends DomainEvent { - eventType = "folder.afterGet" as const; - - getHandlerAbstraction() { - return FolderAfterGetHandler; - } -} diff --git a/packages/api-aco/src/features/folders/GetFolder/feature.ts b/packages/api-aco/src/features/folders/GetFolder/feature.ts index d15bba0c8c0..58c4d520459 100644 --- a/packages/api-aco/src/features/folders/GetFolder/feature.ts +++ b/packages/api-aco/src/features/folders/GetFolder/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { GetFolderRepository } from "./GetFolderRepository.js"; import { GetFolderUseCase } from "./GetFolderUseCase.js"; import { GetFolderWithFolderLevelPermissions } from "./decorators/GetFolderWithFolderLevelPermissions.js"; export const GetFolderFeature = createFeature({ name: "GetFolder", register(container: Container) { + container.register(GetFolderRepository).inSingletonScope(); container.register(GetFolderUseCase); container.registerDecorator(GetFolderWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyRepository.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyRepository.ts new file mode 100644 index 00000000000..d7dfed4fc65 --- /dev/null +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyRepository.ts @@ -0,0 +1,118 @@ +import { Result } from "@webiny/feature/api"; +import { + GetFolderHierarchyRepository as RepositoryAbstraction, + type IGetFolderHierarchyRepository +} from "./abstractions.js"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { + GetFolderHierarchyParams, + GetFolderHierarchyResponse, + Folder +} from "~/folder/folder.types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderPersistenceError } from "~/domain/folder/errors.js"; +import { ROOT_FOLDER } from "~/constants.js"; + +const FIXED_FOLDER_LISTING_LIMIT = 10_000; + +class GetFolderHierarchyRepositoryImpl implements IGetFolderHierarchyRepository { + constructor( + private getEntryById: GetEntryByIdUseCase.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute( + params: GetFolderHierarchyParams + ): Promise> { + const parents: Folder[] = []; + const siblings: Folder[] = []; + + // Get root folders (siblings at root level) + const rootFoldersResult = await this.listLatestEntries.execute(this.folderModel, { + where: { type: params.type, parentId: null }, + limit: FIXED_FOLDER_LISTING_LIMIT + }); + + if (rootFoldersResult.isFail()) { + return Result.fail(new FolderPersistenceError(rootFoldersResult.error)); + } + + const [rootEntries] = rootFoldersResult.value; + siblings.push(...rootEntries.map(entry => EntryToFolderMapper.toFolder(entry))); + + if (params.id === ROOT_FOLDER) { + return Result.ok({ + parents, + siblings + }); + } + + // Get the folder by id + const folderResult = await this.getEntryById.execute(this.folderModel, params.id); + + if (folderResult.isFail()) { + return Result.fail(new FolderPersistenceError(folderResult.error)); + } + + const folder = EntryToFolderMapper.toFolder(folderResult.value); + parents.push(folder); + + // Recursively get all parent folders + const getFolderParentResult = await this.getFolderParent(folder, parents); + if (getFolderParentResult.isFail()) { + return Result.fail(getFolderParentResult.error); + } + + // Get all child folders of all parents (these are siblings at different levels) + const parentIds = parents.map(f => f.id); + + const childFoldersResult = await this.listLatestEntries.execute(this.folderModel, { + where: { type: folder.type, parentId_in: parentIds, id_not_in: parentIds }, + limit: FIXED_FOLDER_LISTING_LIMIT + }); + + if (childFoldersResult.isFail()) { + return Result.fail(new FolderPersistenceError(childFoldersResult.error)); + } + + const [childEntries] = childFoldersResult.value; + siblings.push(...childEntries.map(entry => EntryToFolderMapper.toFolder(entry))); + + return Result.ok({ + parents, + siblings + }); + } + + private async getFolderParent( + folder: Folder, + parents: Folder[] + ): Promise> { + let currentFolder = folder; + + while (currentFolder.parentId) { + const parentResult = await this.getEntryById.execute( + this.folderModel, + currentFolder.parentId + ); + + if (parentResult.isFail()) { + return Result.fail(new FolderPersistenceError(parentResult.error)); + } + + const parentFolder = EntryToFolderMapper.toFolder(parentResult.value); + parents.push(parentFolder); + currentFolder = parentFolder; + } + + return Result.ok(); + } +} + +export const GetFolderHierarchyRepository = RepositoryAbstraction.createImplementation({ + implementation: GetFolderHierarchyRepositoryImpl, + dependencies: [GetEntryByIdUseCase, ListLatestEntriesUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyUseCase.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyUseCase.ts index e20ac2ad21b..5323be659cd 100644 --- a/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyUseCase.ts +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/GetFolderHierarchyUseCase.ts @@ -1,70 +1,26 @@ -import { GetFolderHierarchyUseCase as UseCaseAbstraction } from "./abstractions.js"; -import { ROOT_FOLDER } from "~/constants.js"; +import { Result } from "@webiny/feature/api"; +import { + GetFolderHierarchyUseCase as UseCaseAbstraction, + GetFolderHierarchyRepository +} from "./abstractions.js"; import type { - AcoFolderStorageOperations, - Folder, GetFolderHierarchyParams, GetFolderHierarchyResponse } from "~/folder/folder.types.js"; import { createImplementation } from "@webiny/di"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; - -const FIXED_FOLDER_LISTING_LIMIT = 10_000; class GetFolderHierarchyUseCaseImpl implements UseCaseAbstraction.Interface { - constructor(private storageOperations: AcoFolderStorageOperations) {} - - async execute(params: GetFolderHierarchyParams): Promise { - const parents: Folder[] = []; - const siblings: Folder[] = []; - - const [rootFolders] = await this.storageOperations.listFolders({ - where: { type: params.type, parentId: null }, - limit: FIXED_FOLDER_LISTING_LIMIT - }); - - siblings.push(...rootFolders); - - if (params.id === ROOT_FOLDER) { - return { - parents, - siblings - }; - } - - const folder = await this.storageOperations.getFolder({ id: params.id }); - parents.push(folder); - - const getFolderParent = async (folder: Folder) => { - while (folder.parentId) { - const parentFolder = await this.storageOperations.getFolder({ - id: folder.parentId - }); - parents.push(parentFolder); - folder = parentFolder; - } - }; - - await getFolderParent(folder); - - const parentIds = parents.map(folder => folder.id); - - const [childFolders] = await this.storageOperations.listFolders({ - where: { type: folder.type, parentId_in: parentIds, id_not_in: parentIds }, - limit: FIXED_FOLDER_LISTING_LIMIT - }); - - siblings.push(...childFolders); + constructor(private repository: GetFolderHierarchyRepository.Interface) {} - return { - parents, - siblings - }; + async execute( + params: GetFolderHierarchyParams + ): Promise> { + return await this.repository.execute(params); } } export const GetFolderHierarchyUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: GetFolderHierarchyUseCaseImpl, - dependencies: [FolderStorageOperations] + dependencies: [GetFolderHierarchyRepository] }); diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts index 158d520efd3..e065d840604 100644 --- a/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts @@ -1,18 +1,61 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { GetFolderHierarchyParams, GetFolderHierarchyResponse } from "~/folder/folder.types.js"; +import type { + FolderNotAuthorizedError, + FolderPersistenceError +} from "~/domain/folder/errors.js"; + +/** + * GetFolderHierarchy repository interface + */ +export interface IGetFolderHierarchyRepository { + execute( + params: GetFolderHierarchyParams + ): Promise>; +} -// Use Case Abstraction +export interface IGetFolderHierarchyRepositoryErrors { + persistence: FolderPersistenceError; +} + +type RepositoryError = + IGetFolderHierarchyRepositoryErrors[keyof IGetFolderHierarchyRepositoryErrors]; + +export const GetFolderHierarchyRepository = createAbstraction( + "GetFolderHierarchyRepository" +); + +export namespace GetFolderHierarchyRepository { + export type Interface = IGetFolderHierarchyRepository; + export type Error = RepositoryError; +} + +/** + * GetFolderHierarchy use case interface + */ export interface IGetFolderHierarchyUseCase { - execute: (params: GetFolderHierarchyParams) => Promise; + execute( + params: GetFolderHierarchyParams + ): Promise>; } +export interface IGetFolderHierarchyUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + persistence: FolderPersistenceError; +} + +type UseCaseError = IGetFolderHierarchyUseCaseErrors[keyof IGetFolderHierarchyUseCaseErrors]; + export const GetFolderHierarchyUseCase = createAbstraction( "GetFolderHierarchyUseCase" ); export namespace GetFolderHierarchyUseCase { export type Interface = IGetFolderHierarchyUseCase; + export type Return = Promise>; + export type Error = UseCaseError; } diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/decorators/GetFolderHierarchyWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/decorators/GetFolderHierarchyWithFolderLevelPermissions.ts index e9fab83310f..98537960322 100644 --- a/packages/api-aco/src/features/folders/GetFolderHierarchy/decorators/GetFolderHierarchyWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/decorators/GetFolderHierarchyWithFolderLevelPermissions.ts @@ -1,12 +1,8 @@ -import { createDecorator } from "@webiny/feature/api"; +import { createDecorator, Result } from "@webiny/feature/api"; import type { FolderPermission } from "~/flp/flp.types.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import { GetFolderHierarchyUseCase } from "../abstractions.js"; -import type { - Folder, - GetFolderHierarchyParams, - GetFolderHierarchyResponse -} from "~/folder/folder.types.js"; +import type { Folder, GetFolderHierarchyParams } from "~/folder/folder.types.js"; class GetFolderHierarchyWithFolderLevelPermissionsImpl implements GetFolderHierarchyUseCase.Interface @@ -18,8 +14,14 @@ class GetFolderHierarchyWithFolderLevelPermissionsImpl private decoratee: GetFolderHierarchyUseCase.Interface ) {} - async execute(params: GetFolderHierarchyParams): Promise { - const { siblings, parents } = await this.decoratee.execute(params); + async execute(params: GetFolderHierarchyParams): GetFolderHierarchyUseCase.Return { + const result = await this.decoratee.execute(params); + + if (result.isFail()) { + return Result.fail(result.error); + } + + const { siblings, parents } = result.value; const folders = [...parents, ...siblings]; await Promise.all( @@ -33,10 +35,10 @@ class GetFolderHierarchyWithFolderLevelPermissionsImpl }) ); - return { + return Result.ok({ parents: await this.filterAccessibleFolders(parents), siblings: await this.filterAccessibleFolders(siblings) - }; + }); } private async filterAccessibleFolders(folders: Folder[]): Promise { diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/feature.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/feature.ts index 4be22daa505..c8451fa745b 100644 --- a/packages/api-aco/src/features/folders/GetFolderHierarchy/feature.ts +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { GetFolderHierarchyRepository } from "./GetFolderHierarchyRepository.js"; import { GetFolderHierarchyUseCase } from "./GetFolderHierarchyUseCase.js"; import { GetFolderHierarchyWithFolderLevelPermissions } from "./decorators/GetFolderHierarchyWithFolderLevelPermissions.js"; export const GetFolderHierarchyFeature = createFeature({ name: "GetFolderHierarchy", register(container: Container) { + container.register(GetFolderHierarchyRepository).inSingletonScope(); container.register(GetFolderHierarchyUseCase); container.registerDecorator(GetFolderHierarchyWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/ListFolderLevelPermissionsTargetsUseCase.ts b/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/ListFolderLevelPermissionsTargetsUseCase.ts index 2f5da8773af..9a7089d0c1d 100644 --- a/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/ListFolderLevelPermissionsTargetsUseCase.ts +++ b/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/ListFolderLevelPermissionsTargetsUseCase.ts @@ -1,10 +1,6 @@ +import { Result } from "@webiny/feature/api"; import { ListFolderLevelPermissionsTargetsUseCase as UseCaseAbstraction } from "./abstractions.js"; import { validation } from "@webiny/validation"; -import type { - FolderLevelPermissionsTarget, - FolderLevelPermissionsTargetListMeta -} from "~/folder/folder.types.js"; -import { createImplementation } from "@webiny/di"; import { ListUsersUseCase } from "@webiny/api-core/features/ListUsers"; import { ListTeamsUseCase } from "@webiny/api-core/features/ListTeams"; @@ -14,14 +10,12 @@ class ListFolderLevelPermissionsTargetsUseCaseImpl implements UseCaseAbstraction private listTeams: ListTeamsUseCase.Interface ) {} - public async execute(): Promise< - [FolderLevelPermissionsTarget[], FolderLevelPermissionsTargetListMeta] - > { + public async execute(): UseCaseAbstraction.Return { const adminUsersResult = await this.listAdminUsers.execute(); const teamsResult = await this.listTeams.execute(); if (adminUsersResult.isFail() || teamsResult.isFail()) { - return [[], { totalCount: 0 }]; + return Result.ok([[], { totalCount: 0 }]); } const adminUsers = adminUsersResult.value; @@ -69,12 +63,11 @@ class ListFolderLevelPermissionsTargetsUseCaseImpl implements UseCaseAbstraction const results = [...teamTargets, ...adminUserTargets]; const meta = { totalCount: results.length }; - return [results, meta]; + return Result.ok([results, meta]); } } -export const ListFolderLevelPermissionsTargetsUseCase = createImplementation({ - abstraction: UseCaseAbstraction, +export const ListFolderLevelPermissionsTargetsUseCase = UseCaseAbstraction.createImplementation({ implementation: ListFolderLevelPermissionsTargetsUseCaseImpl, dependencies: [ListUsersUseCase, ListTeamsUseCase] }); diff --git a/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/abstractions.ts b/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/abstractions.ts index ed9dbd3cfae..18103bcd923 100644 --- a/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/abstractions.ts +++ b/packages/api-aco/src/features/folders/ListFolderLevelPermissionsTargets/abstractions.ts @@ -1,4 +1,4 @@ -import { createAbstraction } from "@webiny/feature/api"; +import { createAbstraction, Result } from "@webiny/feature/api"; import type { FolderLevelPermissionsTarget, FolderLevelPermissionsTargetListMeta @@ -6,7 +6,9 @@ import type { // Use Case Abstraction export interface IListFolderLevelPermissionsTargetsUseCase { - execute: () => Promise<[FolderLevelPermissionsTarget[], FolderLevelPermissionsTargetListMeta]>; + execute: () => Promise< + Result<[FolderLevelPermissionsTarget[], FolderLevelPermissionsTargetListMeta]> + >; } export const ListFolderLevelPermissionsTargetsUseCase = @@ -16,4 +18,7 @@ export const ListFolderLevelPermissionsTargetsUseCase = export namespace ListFolderLevelPermissionsTargetsUseCase { export type Interface = IListFolderLevelPermissionsTargetsUseCase; + export type Return = Promise< + Result<[FolderLevelPermissionsTarget[], FolderLevelPermissionsTargetListMeta]> + >; } diff --git a/packages/api-aco/src/features/folders/ListFolders/ListFoldersRepository.ts b/packages/api-aco/src/features/folders/ListFolders/ListFoldersRepository.ts new file mode 100644 index 00000000000..6db67d19deb --- /dev/null +++ b/packages/api-aco/src/features/folders/ListFolders/ListFoldersRepository.ts @@ -0,0 +1,53 @@ +import { Result } from "@webiny/feature/api"; +import { + ListFoldersRepository as RepositoryAbstraction, + type IListFoldersRepository +} from "./abstractions.js"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { Folder, ListFoldersParams } from "~/folder/folder.types.js"; +import type { ListMeta } from "~/types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderPersistenceError } from "~/domain/folder/errors.js"; +import { createListSort } from "~/utils/createListSort.js"; +import type { ListSort } from "~/types.js"; + +class ListFoldersRepositoryImpl implements IListFoldersRepository { + constructor( + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute( + params: ListFoldersParams + ): Promise> { + const { sort, where } = params; + + const listSort = + sort || + ({ + title: "ASC" + } as unknown as ListSort); + + const result = await this.listLatestEntries.execute(this.folderModel, { + ...params, + sort: createListSort(listSort), + where: { + ...(where || {}) + } + }); + + if (result.isFail()) { + return Result.fail(new FolderPersistenceError(result.error)); + } + + const [entries, meta] = result.value; + const folders = entries.map(entry => EntryToFolderMapper.toFolder(entry)); + return Result.ok([folders, meta]); + } +} + +export const ListFoldersRepository = RepositoryAbstraction.createImplementation({ + implementation: ListFoldersRepositoryImpl, + dependencies: [ListLatestEntriesUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts b/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts index f5216ed4c47..dd2613ba6ff 100644 --- a/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts +++ b/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts @@ -1,23 +1,24 @@ +import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; -import { ListFoldersUseCase as UseCaseAbstraction } from "./abstractions.js"; -import type { - AcoFolderStorageOperations, - Folder, - ListFoldersParams -} from "~/folder/folder.types.js"; +import { + ListFoldersUseCase as UseCaseAbstraction, + ListFoldersRepository +} from "./abstractions.js"; +import type { Folder, ListFoldersParams } from "~/folder/folder.types.js"; import type { ListMeta } from "~/types.js"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; class ListFoldersUseCaseImpl implements UseCaseAbstraction.Interface { - constructor(private storageOperations: AcoFolderStorageOperations) {} + constructor(private repository: ListFoldersRepository.Interface) {} - async execute(params: ListFoldersParams): Promise<[Folder[], ListMeta]> { - return await this.storageOperations.listFolders(params); + async execute( + params: ListFoldersParams + ): Promise> { + return await this.repository.execute(params); } } export const ListFoldersUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: ListFoldersUseCaseImpl, - dependencies: [FolderStorageOperations] + dependencies: [ListFoldersRepository] }); diff --git a/packages/api-aco/src/features/folders/ListFolders/abstractions.ts b/packages/api-aco/src/features/folders/ListFolders/abstractions.ts index d574027df03..f3377d1085f 100644 --- a/packages/api-aco/src/features/folders/ListFolders/abstractions.ts +++ b/packages/api-aco/src/features/folders/ListFolders/abstractions.ts @@ -1,14 +1,48 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { Folder, ListFoldersParams } from "~/folder/folder.types.js"; import type { ListMeta } from "~/types.js"; +import type { FolderNotAuthorizedError, FolderPersistenceError } from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * ListFolders repository interface + */ +export interface IListFoldersRepository { + execute(params: ListFoldersParams): Promise>; +} + +export interface IListFoldersRepositoryErrors { + persistence: FolderPersistenceError; +} + +type RepositoryError = IListFoldersRepositoryErrors[keyof IListFoldersRepositoryErrors]; + +export const ListFoldersRepository = + createAbstraction("ListFoldersRepository"); + +export namespace ListFoldersRepository { + export type Interface = IListFoldersRepository; + export type Error = RepositoryError; +} + +/** + * ListFolders use case interface + */ export interface IListFoldersUseCase { - execute: (params: ListFoldersParams) => Promise<[Folder[], ListMeta]>; + execute(params: ListFoldersParams): Promise>; } +export interface IListFoldersUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + persistence: FolderPersistenceError; +} + +type UseCaseError = IListFoldersUseCaseErrors[keyof IListFoldersUseCaseErrors]; + export const ListFoldersUseCase = createAbstraction("ListFoldersUseCase"); export namespace ListFoldersUseCase { export type Interface = IListFoldersUseCase; + export type Return = Promise>; + export type Error = UseCaseError; } diff --git a/packages/api-aco/src/features/folders/ListFolders/decorators/ListFoldersWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/ListFolders/decorators/ListFoldersWithFolderLevelPermissions.ts index 12a9ee0dc47..2845190c9d3 100644 --- a/packages/api-aco/src/features/folders/ListFolders/decorators/ListFoldersWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/ListFolders/decorators/ListFoldersWithFolderLevelPermissions.ts @@ -1,10 +1,9 @@ -import { createDecorator } from "@webiny/feature/api"; +import { Result } from "@webiny/feature/api"; import type { Folder, ListFoldersParams } from "~/folder/folder.types.js"; import { ListFoldersUseCase } from "../abstractions.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import type { FolderPermission } from "~/flp/flp.types.js"; import { ROOT_FOLDER } from "~/constants.js"; -import type { ListMeta } from "~/types.js"; class ListFoldersWithFolderLevelPermissionsImpl implements ListFoldersUseCase.Interface { private flpCatalog: Map = new Map(); @@ -14,8 +13,13 @@ class ListFoldersWithFolderLevelPermissionsImpl implements ListFoldersUseCase.In private decoratee: ListFoldersUseCase.Interface ) {} - async execute(params: ListFoldersParams): Promise<[Folder[], ListMeta]> { - const [folders, meta] = await this.decoratee.execute(params); + async execute(params: ListFoldersParams): ListFoldersUseCase.Return { + const result = await this.decoratee.execute(params); + if (result.isFail()) { + return Result.fail(result.error); + } + + const [folders, meta] = result.value; // Fetch FLP records for ROOT folders and populate the catalog. const rootFlps = await this.folderLevelPermissions.listFolderLevelPermissions({ @@ -61,7 +65,7 @@ class ListFoldersWithFolderLevelPermissionsImpl implements ListFoldersUseCase.In }) ); - return [foldersWithPermissions.filter(Boolean) as Folder[], meta]; + return Result.ok([foldersWithPermissions.filter(Boolean) as Folder[], meta]); } private hasFlp(id: string): boolean { @@ -77,8 +81,7 @@ class ListFoldersWithFolderLevelPermissionsImpl implements ListFoldersUseCase.In } } -export const ListFoldersWithFolderLevelPermissions = createDecorator({ - abstraction: ListFoldersUseCase, +export const ListFoldersWithFolderLevelPermissions = ListFoldersUseCase.createDecorator({ decorator: ListFoldersWithFolderLevelPermissionsImpl, dependencies: [FolderLevelPermissions] }); diff --git a/packages/api-aco/src/features/folders/ListFolders/feature.ts b/packages/api-aco/src/features/folders/ListFolders/feature.ts index e78ee93574b..85c664c3b40 100644 --- a/packages/api-aco/src/features/folders/ListFolders/feature.ts +++ b/packages/api-aco/src/features/folders/ListFolders/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { ListFoldersRepository } from "./ListFoldersRepository.js"; import { ListFoldersUseCase } from "./ListFoldersUseCase.js"; import { ListFoldersWithFolderLevelPermissions } from "./decorators/ListFoldersWithFolderLevelPermissions.js"; export const ListFoldersFeature = createFeature({ name: "ListFolders", register(container: Container) { + container.register(ListFoldersRepository).inSingletonScope(); container.register(ListFoldersUseCase); container.registerDecorator(ListFoldersWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderRepository.ts b/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderRepository.ts new file mode 100644 index 00000000000..183e1500514 --- /dev/null +++ b/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderRepository.ts @@ -0,0 +1,141 @@ +import omit from "lodash/omit.js"; +import { Result } from "@webiny/feature/api"; +import { + UpdateFolderRepository as RepositoryAbstraction, + type IUpdateFolderRepository +} from "./abstractions.js"; +import { UpdateEntryUseCase } from "@webiny/api-headless-cms/features/contentEntry/UpdateEntry"; +import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/features/contentEntry/GetEntryById"; +import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/features/contentEntry/ListEntries"; +import { FolderModel } from "~/domain/folder/abstractions.js"; +import type { Folder, UpdateFolderParams } from "~/folder/folder.types.js"; +import { EntryToFolderMapper } from "../shared/EntryToFolderMapper.js"; +import { FolderPersistenceError, FolderValidationError } from "~/domain/folder/errors.js"; +import { ENTRY_META_FIELDS } from "@webiny/api-headless-cms/constants.js"; +import { Path } from "~/utils/Path.js"; + +class UpdateFolderRepositoryImpl implements IUpdateFolderRepository { + constructor( + private updateEntry: UpdateEntryUseCase.Interface, + private getEntryById: GetEntryByIdUseCase.Interface, + private listLatestEntries: ListLatestEntriesUseCase.Interface, + private folderModel: FolderModel.Interface + ) {} + + async execute( + id: string, + data: UpdateFolderParams + ): Promise> { + // Get the original folder + const originalResult = await this.getEntryById.execute(this.folderModel, id); + + if (originalResult.isFail()) { + return Result.fail(new FolderPersistenceError(originalResult.error)); + } + + const original = EntryToFolderMapper.toFolder(originalResult.value); + + // Check if folder with new slug already exists + const checkResult = await this.checkExistingFolder({ + id, + type: original.type, + slug: data.slug || original.slug, + parentId: data.parentId !== undefined ? data.parentId : original.parentId + }); + + if (checkResult.isFail()) { + return Result.fail(checkResult.error); + } + + // Create new path if slug or parentId changed + const pathResult = await this.createFolderPath({ + slug: data.slug || original.slug, + parentId: data.parentId !== undefined ? data.parentId : original.parentId + }); + + if (pathResult.isFail()) { + return Result.fail(pathResult.error); + } + + // Prepare the input by merging original with updates + const input = { + ...omit(original, [...ENTRY_META_FIELDS, "id", "entryId"]), + ...data, + path: pathResult.value + }; + + const result = await this.updateEntry.execute(this.folderModel, id, input); + + if (result.isFail()) { + return Result.fail(new FolderPersistenceError(result.error)); + } + + const updatedFolder = EntryToFolderMapper.toFolder(result.value); + return Result.ok(updatedFolder); + } + + private async checkExistingFolder(params: { + id: string; + type: string; + slug: string; + parentId?: string | null; + }): Promise> { + const { id, type, slug, parentId } = params; + + const result = await this.listLatestEntries.execute(this.folderModel, { + where: { + latest: true, + type, + slug, + parentId, + id_not: id + }, + limit: 1 + }); + + if (result.isFail()) { + return Result.fail(new FolderPersistenceError(result.error)); + } + + const [entries] = result.value; + + if (entries.length > 0) { + return Result.fail( + new FolderValidationError( + `Folder with slug "${slug}" already exists at this level.` + ) + ); + } + + return Result.ok(); + } + + private async createFolderPath(params: { + slug: string; + parentId?: string | null; + }): Promise> { + const { slug, parentId } = params; + + if (!parentId) { + return Result.ok(Path.create(slug)); + } + + const parentResult = await this.getEntryById.execute(this.folderModel, parentId); + + if (parentResult.isFail()) { + return Result.fail( + new FolderPersistenceError( + new Error("Parent folder not found. Unable to create the folder path") + ) + ); + } + + const parentFolder = EntryToFolderMapper.toFolder(parentResult.value); + return Result.ok(Path.create(slug, parentFolder.path)); + } +} + +export const UpdateFolderRepository = RepositoryAbstraction.createImplementation({ + implementation: UpdateFolderRepositoryImpl, + dependencies: [UpdateEntryUseCase, GetEntryByIdUseCase, ListLatestEntriesUseCase, FolderModel] +}); diff --git a/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderUseCase.ts b/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderUseCase.ts index 456246622f8..865ddbd368d 100644 --- a/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderUseCase.ts +++ b/packages/api-aco/src/features/folders/UpdateFolder/UpdateFolderUseCase.ts @@ -1,26 +1,38 @@ +import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; import { EventPublisher, EventPublisher as EventPublisherAbstraction } from "@webiny/api-core/features/EventPublisher"; -import { UpdateFolderUseCase as UseCaseAbstraction } from "./abstractions.js"; +import { + UpdateFolderUseCase as UseCaseAbstraction, + UpdateFolderRepository +} from "./abstractions.js"; +import { GetFolderRepository } from "../GetFolder/abstractions.js"; import { FolderBeforeUpdateEvent, FolderAfterUpdateEvent } from "./events.js"; -import type { - Folder, - UpdateFolderParams, - AcoFolderStorageOperations -} from "~/folder/folder.types.js"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; +import type { Folder, UpdateFolderParams } from "~/folder/folder.types.js"; class UpdateFolderUseCaseImpl implements UseCaseAbstraction.Interface { constructor( private eventPublisher: EventPublisherAbstraction.Interface, - private storageOperations: AcoFolderStorageOperations + private getFolderRepository: GetFolderRepository.Interface, + private repository: UpdateFolderRepository.Interface ) {} - async execute(id: string, input: UpdateFolderParams): Promise { + async execute( + id: string, + input: UpdateFolderParams + ): Promise> { const useCaseInput = { id, data: input }; - const original = await this.storageOperations.getFolder({ id }); + + // Get original folder for events + const originalResult = await this.getFolderRepository.execute(id); + + if (originalResult.isFail()) { + return originalResult; + } + + const original = originalResult.value; // Publish before update event const beforeUpdateEvent = new FolderBeforeUpdateEvent({ @@ -31,10 +43,13 @@ class UpdateFolderUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(beforeUpdateEvent); // Execute the update operation - const folder = await this.storageOperations.updateFolder({ - id, - data: input - }); + const result = await this.repository.execute(id, input); + + if (result.isFail()) { + return result; + } + + const folder = result.value; // Publish after update event const afterUpdateEvent = new FolderAfterUpdateEvent({ @@ -45,12 +60,12 @@ class UpdateFolderUseCaseImpl implements UseCaseAbstraction.Interface { await this.eventPublisher.publish(afterUpdateEvent); - return folder; + return Result.ok(folder); } } export const UpdateFolderUseCase = createImplementation({ abstraction: UseCaseAbstraction, implementation: UpdateFolderUseCaseImpl, - dependencies: [EventPublisher, FolderStorageOperations] + dependencies: [EventPublisher, GetFolderRepository, UpdateFolderRepository] }); diff --git a/packages/api-aco/src/features/folders/UpdateFolder/abstractions.ts b/packages/api-aco/src/features/folders/UpdateFolder/abstractions.ts index cf46c40a532..bdd04613c92 100644 --- a/packages/api-aco/src/features/folders/UpdateFolder/abstractions.ts +++ b/packages/api-aco/src/features/folders/UpdateFolder/abstractions.ts @@ -1,16 +1,60 @@ import { createAbstraction } from "@webiny/feature/api"; +import type { Result } from "@webiny/feature/api"; import type { DomainEvent, IEventHandler } from "@webiny/api-core/features/EventPublisher"; import type { Folder, UpdateFolderParams } from "~/folder/folder.types.js"; +import { + FolderCannotMoveToNewParent, + type FolderNotAuthorizedError, + type FolderNotFoundError, + type FolderPersistenceError, + type FolderValidationError +} from "~/domain/folder/errors.js"; -// Use Case Abstraction +/** + * UpdateFolder repository interface + */ +export interface IUpdateFolderRepository { + execute(id: string, data: UpdateFolderParams): Promise>; +} + +export interface IUpdateFolderRepositoryErrors { + persistence: FolderPersistenceError; + validation: FolderValidationError; +} + +type RepositoryError = IUpdateFolderRepositoryErrors[keyof IUpdateFolderRepositoryErrors]; + +export const UpdateFolderRepository = + createAbstraction("UpdateFolderRepository"); + +export namespace UpdateFolderRepository { + export type Interface = IUpdateFolderRepository; + export type Error = RepositoryError; +} + +/** + * UpdateFolder use case interface + */ export interface IUpdateFolderUseCase { - execute: (id: string, data: UpdateFolderParams) => Promise; + execute(id: string, data: UpdateFolderParams): Promise>; } +export interface IUpdateFolderUseCaseErrors { + notAuthorized: FolderNotAuthorizedError; + notFound: FolderNotFoundError; + cannotMoveToNewParent: FolderCannotMoveToNewParent; + persistence: FolderPersistenceError; + validation: FolderValidationError; +} + +type UseCaseError = IUpdateFolderUseCaseErrors[keyof IUpdateFolderUseCaseErrors]; + export const UpdateFolderUseCase = createAbstraction("UpdateFolderUseCase"); export namespace UpdateFolderUseCase { export type Interface = IUpdateFolderUseCase; + export type Return = Promise>; + export type Error = UseCaseError; } // Event Payload Types diff --git a/packages/api-aco/src/features/folders/UpdateFolder/decorators/UpdateFolderWithFolderLevelPermissions.ts b/packages/api-aco/src/features/folders/UpdateFolder/decorators/UpdateFolderWithFolderLevelPermissions.ts index a004e15790f..7c8281c5e41 100644 --- a/packages/api-aco/src/features/folders/UpdateFolder/decorators/UpdateFolderWithFolderLevelPermissions.ts +++ b/packages/api-aco/src/features/folders/UpdateFolder/decorators/UpdateFolderWithFolderLevelPermissions.ts @@ -1,28 +1,32 @@ -import { createDecorator } from "@webiny/feature/api"; -import WError from "@webiny/error"; +import { createDecorator, Result } from "@webiny/feature/api"; import type { UpdateFolderParams } from "~/folder/folder.types.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import { UpdateFolderUseCase } from "../abstractions.js"; -import { FolderStorageOperations } from "~/features/folders/shared/abstractions.js"; -import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; +import { GetFolderUseCase } from "~/features/folders/GetFolder/index.js"; +import { FolderCannotMoveToNewParent, FolderValidationError } from "~/domain/folder/errors.js"; class UpdateFolderWithFolderLevelPermissionsImpl implements UpdateFolderUseCase.Interface { private folderLevelPermissions: FolderLevelPermissions.Interface; - private readonly storageOperations: FolderStorageOperations.Interface; private readonly decoretee: UpdateFolderUseCase.Interface; constructor( + private getFolder: GetFolderUseCase.Interface, folderLevelPermissions: FolderLevelPermissions.Interface, - storageOperations: FolderStorageOperations.Interface, decoretee: UpdateFolderUseCase.Interface ) { this.folderLevelPermissions = folderLevelPermissions; - this.storageOperations = storageOperations; this.decoretee = decoretee; } - async execute(id: string, params: UpdateFolderParams) { - const original = await this.storageOperations.getFolder({ id }); + async execute(id: string, params: UpdateFolderParams): UpdateFolderUseCase.Return { + const originalResult = await this.getFolder.execute(id); + + if (originalResult.isFail()) { + return Result.fail(originalResult.error); + } + + const original = originalResult.value; + const originalPermissions = await this.folderLevelPermissions.getFolderLevelPermissions(id); // Let's ensure current identity's permission allows the update operation. @@ -42,62 +46,68 @@ class UpdateFolderWithFolderLevelPermissionsImpl implements UpdateFolderUseCase. }); if (!stillHasAccess) { - throw new WError( - `Cannot continue because you would loose access to this folder.`, - "CANNOT_LOOSE_FOLDER_ACCESS" + return Result.fail( + new FolderValidationError( + `Cannot continue because you would loose access to this folder.` + ) ); } // Validate data. if (Array.isArray(params.permissions)) { - params.permissions.forEach(permission => { + for (const permission of params.permissions) { const targetIsValid = permission.target.startsWith("admin:") || permission.target.startsWith("team:"); if (!targetIsValid) { - throw new Error(`Permission target "${permission.target}" is not valid.`); + return Result.fail( + new FolderValidationError( + `Permission target "${permission.target}" is not valid.` + ) + ); } if (permission.inheritedFrom) { - throw new Error(`Permission "inheritedFrom" cannot be set manually.`); + return Result.fail( + new FolderValidationError( + `Permission "inheritedFrom" cannot be set manually.` + ) + ); } - }); + } } // Parent change is not allowed if the user doesn't have access to the new parent. if (params.parentId && params.parentId !== original.parentId) { - try { - // Getting the parent folder permissions will throw an error if the user doesn't have access. - const parentPermissions = - await this.folderLevelPermissions.getFolderLevelPermissions(params.parentId); - - await this.folderLevelPermissions.ensureCanAccessFolder({ - permissions: parentPermissions, - rwd: "w" - }); - } catch (e) { - if (e instanceof NotAuthorizedError) { - throw new WError( - `Cannot move folder to a new parent because you don't have access to the new parent.`, - "CANNOT_MOVE_FOLDER_TO_NEW_PARENT" - ); - } + // Getting the parent folder permissions will throw an error if the user doesn't have access. + const parentPermissions = await this.folderLevelPermissions.getFolderLevelPermissions( + params.parentId + ); + + const canAccessFolder = await this.folderLevelPermissions.canAccessFolder({ + permissions: parentPermissions, + rwd: "w" + }); - // If we didn't receive the expected error, we still want to throw it. - throw e; + if (!canAccessFolder) { + return Result.fail(new FolderCannotMoveToNewParent()); } } - const folder = await this.decoretee.execute(id, params); + const result = await this.decoretee.execute(id, params); + + if (result.isFail()) { + return Result.fail(result.error); + } - return { - ...folder, + return Result.ok({ + ...result.value, permissions - }; + }); } } export const UpdateFolderWithFolderLevelPermissions = createDecorator({ abstraction: UpdateFolderUseCase, decorator: UpdateFolderWithFolderLevelPermissionsImpl, - dependencies: [FolderLevelPermissions, FolderStorageOperations] + dependencies: [GetFolderUseCase, FolderLevelPermissions] }); diff --git a/packages/api-aco/src/features/folders/UpdateFolder/feature.ts b/packages/api-aco/src/features/folders/UpdateFolder/feature.ts index 4f243fc91ae..d57f22b6cc2 100644 --- a/packages/api-aco/src/features/folders/UpdateFolder/feature.ts +++ b/packages/api-aco/src/features/folders/UpdateFolder/feature.ts @@ -1,11 +1,13 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; +import { UpdateFolderRepository } from "./UpdateFolderRepository.js"; import { UpdateFolderUseCase } from "./UpdateFolderUseCase.js"; import { UpdateFolderWithFolderLevelPermissions } from "./decorators/UpdateFolderWithFolderLevelPermissions.js"; export const UpdateFolderFeature = createFeature({ name: "UpdateFolder", register(container: Container) { + container.register(UpdateFolderRepository).inSingletonScope(); container.register(UpdateFolderUseCase); container.registerDecorator(UpdateFolderWithFolderLevelPermissions); } diff --git a/packages/api-aco/src/features/folders/shared/abstractions.ts b/packages/api-aco/src/features/folders/shared/abstractions.ts index 61b1532068f..95f27f57bf2 100644 --- a/packages/api-aco/src/features/folders/shared/abstractions.ts +++ b/packages/api-aco/src/features/folders/shared/abstractions.ts @@ -1,13 +1,6 @@ import { createAbstraction } from "@webiny/feature/api"; import type { AcoStorageOperations as IAcoStorageOperations } from "~/types.js"; -export const FolderStorageOperations = - createAbstraction("FolderStorageOperations"); - -export namespace FolderStorageOperations { - export type Interface = IAcoStorageOperations["folder"]; -} - export const FilterStorageOperations = createAbstraction("FilterStorageOperations"); diff --git a/packages/api-aco/src/flp/tasks/syncFlp.task.ts b/packages/api-aco/src/flp/tasks/syncFlp.task.ts index 915013bfe5a..2f47764d933 100644 --- a/packages/api-aco/src/flp/tasks/syncFlp.task.ts +++ b/packages/api-aco/src/flp/tasks/syncFlp.task.ts @@ -7,6 +7,8 @@ import { type IUpdateFlpTaskInput } from "~/types.js"; import { PB_PAGE_TYPE, FM_FILE_TYPE } from "~/constants.js"; +import { GetFolderUseCase } from "~/features/folders/GetFolder/index.js"; +import { ListFoldersUseCase } from "~/features/folders/ListFolders/index.js"; class SyncFlpTask { public init = () => { @@ -23,14 +25,18 @@ class SyncFlpTask { return response.aborted(); } + const getFolder = context.container.resolve(GetFolderUseCase); + const listFolders = context.container.resolve(ListFoldersUseCase); + /** * `folderId` provided in the task input. We need to: * * - update the FLP records for the found folder and all its descendants. */ if (input.folderId) { - const folder = await context.security.withoutAuthorization(() => { - return context.aco.folder.get(input.folderId!); + const folder = await context.security.withoutAuthorization(async () => { + const result = await getFolder.execute(input.folderId!); + return result.value; }); await context.tasks.trigger({ @@ -66,8 +72,8 @@ class SyncFlpTask { } for (const folderType of folderTypes) { - const [folders] = await context.security.withoutAuthorization(() => { - return context.aco.folder.list({ + const result = await context.security.withoutAuthorization(() => { + return listFolders.execute({ where: { type: folderType, parentId: null @@ -75,6 +81,8 @@ class SyncFlpTask { }); }); + const [folders] = result.value; + for (const folder of folders) { await context.tasks.trigger({ definition: UPDATE_FLP_TASK_ID, @@ -104,8 +112,8 @@ class SyncFlpTask { * - update the FLP records for the found folders and all its descendants. */ if (input.type) { - const [folders] = await context.security.withoutAuthorization(() => { - return context.aco.folder.list({ + const result = await context.security.withoutAuthorization(() => { + return listFolders.execute({ where: { type: input.type!, parentId: null @@ -113,6 +121,8 @@ class SyncFlpTask { }); }); + const [folders] = result.value; + for (const folder of folders) { await context.tasks.trigger({ definition: UPDATE_FLP_TASK_ID, diff --git a/packages/api-aco/src/folder/createFolderModelModifier.ts b/packages/api-aco/src/folder/createFolderModelModifier.ts index 76d5e9f81e6..b43b1696f61 100644 --- a/packages/api-aco/src/folder/createFolderModelModifier.ts +++ b/packages/api-aco/src/folder/createFolderModelModifier.ts @@ -2,7 +2,7 @@ import { Plugin } from "@webiny/plugins"; import type { CmsPrivateModelFull } from "@webiny/api-headless-cms"; import { createModelField } from "@webiny/api-headless-cms"; import type { CmsModelField as BaseModelField } from "@webiny/api-headless-cms/types/index.js"; -import { FOLDER_MODEL_ID } from "~/folder/folder.model.js"; +import { FOLDER_MODEL_ID } from "~/domain/folder/folder.model.js"; export type CmsModelField = Omit & { modelIds?: string[] }; diff --git a/packages/api-aco/src/folder/ensureFolderIsEmpty.ts b/packages/api-aco/src/folder/ensureFolderIsEmpty.ts index 631373bad1a..c433e869daa 100644 --- a/packages/api-aco/src/folder/ensureFolderIsEmpty.ts +++ b/packages/api-aco/src/folder/ensureFolderIsEmpty.ts @@ -2,6 +2,7 @@ import WebinyError from "@webiny/error"; import type { AcoContext, Folder } from "~/types.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/index.js"; +import { ListFoldersUseCase } from "~/features/folders/ListFolders/index.js"; interface EnsureFolderIsEmptyParams { context: AcoContext; @@ -15,10 +16,11 @@ export const ensureFolderIsEmpty = async ({ hasContentCallback }: EnsureFolderIsEmptyParams) => { const flp = context.container.resolve(FolderLevelPermissions); + const listFolders = context.container.resolve(ListFoldersUseCase); const hasFoldersCallback = async () => { const { id, type } = folder; - const [folders] = await context.aco.folder.list({ + const result = await listFolders.execute({ where: { type, parentId: id @@ -26,6 +28,8 @@ export const ensureFolderIsEmpty = async ({ limit: 1 }); + const [folders] = result.value; + return folders.length > 0; }; diff --git a/packages/api-aco/src/folder/folder.crud.ts b/packages/api-aco/src/folder/folder.crud.ts deleted file mode 100644 index 2b797de8fde..00000000000 --- a/packages/api-aco/src/folder/folder.crud.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { AcoFolderCrud } from "./folder.types.js"; -import { type ListFoldersParams } from "./folder.types.js"; - -import type { Folder } from "~/types.js"; -import { UpdateFolderUseCase } from "~/features/folders/UpdateFolder/abstractions.js"; -import { DeleteFolderUseCase } from "~/features/folders/DeleteFolder/index.js"; -import { CreateFolderUseCase } from "~/features/folders/CreateFolder/index.js"; -import { GetFolderUseCase } from "~/features/folders/GetFolder/index.js"; -import { ListFoldersUseCase } from "~/features/folders/ListFolders/index.js"; -import { GetFolderHierarchyUseCase } from "~/features/folders/GetFolderHierarchy/index.js"; -import { GetAncestorsUseCase } from "~/features/folders/GetAncestors/index.js"; -import { ListFolderLevelPermissionsTargetsUseCase } from "~/features/folders/ListFolderLevelPermissionsTargets/index.js"; -import type { Container } from "@webiny/di"; - -const FIXED_FOLDER_LISTING_LIMIT = 10_000; - -interface CreateFolderCrudMethodsParams { - container: Container; -} - -export const createFolderCrudMethods = ({ - container -}: CreateFolderCrudMethodsParams): AcoFolderCrud => { - return { - async get(id: string) { - const getFolderUseCase = container.resolve(GetFolderUseCase); - return await getFolderUseCase.execute({ id }); - }, - - async list(params: ListFoldersParams) { - const listFoldersUseCase = container.resolve(ListFoldersUseCase); - return await listFoldersUseCase.execute(params); - }, - - async listAll(params) { - return await this.list({ - ...params, - limit: FIXED_FOLDER_LISTING_LIMIT - }); - }, - - async getFolderHierarchy(params) { - const getFolderHierarchyUseCase = container.resolve(GetFolderHierarchyUseCase); - return await getFolderHierarchyUseCase.execute(params); - }, - - async create(data) { - const createFolderUseCase = container.resolve(CreateFolderUseCase); - return await createFolderUseCase.execute(data); - }, - - async delete(id) { - const deleteFolderUseCase = container.resolve(DeleteFolderUseCase); - return await deleteFolderUseCase.execute({ id }); - }, - - async update(id, data) { - const updateFolderUseCase = container.resolve(UpdateFolderUseCase); - return await updateFolderUseCase.execute(id, data); - }, - - async getAncestors(folder: Folder) { - const getAncestorsUseCase = container.resolve(GetAncestorsUseCase); - return getAncestorsUseCase.execute({ folder }); - }, - - async listFolderLevelPermissionsTargets() { - const listFolderLevelPermissionsTargetsUseCase = container.resolve( - ListFolderLevelPermissionsTargetsUseCase - ); - return await listFolderLevelPermissionsTargetsUseCase.execute(); - } - }; -}; diff --git a/packages/api-aco/src/folder/folder.gql.ts b/packages/api-aco/src/folder/folder.gql.ts index a6ac11914e8..559a73caa25 100644 --- a/packages/api-aco/src/folder/folder.gql.ts +++ b/packages/api-aco/src/folder/folder.gql.ts @@ -9,8 +9,15 @@ import { compress } from "~/utils/compress.js"; import type { AcoContext, Folder } from "~/types.js"; import type { FolderLevelPermission } from "~/flp/flp.types.js"; -import { FOLDER_MODEL_ID } from "~/folder/folder.model.js"; import { FolderLevelPermissions } from "~/features/flp/FolderLevelPermissions/index.js"; +import { GetFolderUseCase } from "~/features/folders/GetFolder/abstractions.js"; +import { ListFoldersUseCase } from "~/features/folders/ListFolders/abstractions.js"; +import { CreateFolderUseCase } from "~/features/folders/CreateFolder/abstractions.js"; +import { UpdateFolderUseCase } from "~/features/folders/UpdateFolder/abstractions.js"; +import { DeleteFolderUseCase } from "~/features/folders/DeleteFolder/abstractions.js"; +import { GetFolderHierarchyUseCase } from "~/features/folders/GetFolderHierarchy/abstractions.js"; +import { ListFolderLevelPermissionsTargetsUseCase } from "~/features/folders/ListFolderLevelPermissionsTargets/abstractions.js"; +import { FolderModel } from "~/domain/folder/abstractions.js"; export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { const folderGraphQL = new GraphQLSchemaPlugin({ @@ -36,21 +43,31 @@ export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { }, AcoQuery: { getFolderModel(_, __, context) { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.cms.getModel(FOLDER_MODEL_ID); + return context.container.resolve(FolderModel); }); }, getFolder: async (_, { id }, context) => { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.aco.folder.get(id); + const getFolderUseCase = context.container.resolve(GetFolderUseCase); + const result = await getFolderUseCase.execute(id); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, listFolders: async (_, args: any, context) => { try { ensureAuthentication(context); - const [entries, meta] = await context.aco.folder.list(args); + const listFoldersUseCase = context.container.resolve(ListFoldersUseCase); + const result = await listFoldersUseCase.execute(args); + if (result.isFail()) { + throw result.error; + } + const [entries, meta] = result.value; return new ListResponse(entries, meta); } catch (e) { return new ErrorResponse(e); @@ -61,7 +78,12 @@ export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { ensureAuthentication(context); const flp = context.container.resolve(FolderLevelPermissions); - const [entries] = await context.aco.folder.list(args); + const listFoldersUseCase = context.container.resolve(ListFoldersUseCase); + const result = await listFoldersUseCase.execute(args); + if (result.isFail()) { + throw result.error; + } + const [entries] = result.value; const foldersPromises = entries.map(folder => { const canManageStructure = flp.canManageFolderStructure( folder as unknown as FolderLevelPermission @@ -105,9 +127,15 @@ export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { }, getFolderHierarchy: async (_, args: any, context) => { try { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.aco.folder.getFolderHierarchy(args); + const getFolderHierarchyUseCase = + context.container.resolve(GetFolderHierarchyUseCase); + const result = await getFolderHierarchyUseCase.execute(args); + if (result.isFail()) { + throw result.error; + } + return result.value; }); } catch (e) { return new ErrorResponse(e); @@ -116,8 +144,14 @@ export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { listFolderLevelPermissionsTargets: async (_, args: any, context) => { try { ensureAuthentication(context); - const [entries, meta] = - await context.aco.folder.listFolderLevelPermissionsTargets(); + const listTargetsUseCase = context.container.resolve( + ListFolderLevelPermissionsTargetsUseCase + ); + const result = await listTargetsUseCase.execute(); + if (result.isFail()) { + throw result.error; + } + const [entries, meta] = result.value; return new ListResponse(entries, meta); } catch (e) { return new ErrorResponse(e); @@ -126,21 +160,36 @@ export const createFoldersSchema = (params: CreateFolderTypeDefsParams) => { }, AcoMutation: { createFolder: async (_, { data }, context) => { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.aco.folder.create(data); + const createFolderUseCase = context.container.resolve(CreateFolderUseCase); + const result = await createFolderUseCase.execute(data); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, updateFolder: async (_, { id, data }, context) => { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.aco.folder.update(id, data); + const updateFolderUseCase = context.container.resolve(UpdateFolderUseCase); + const result = await updateFolderUseCase.execute(id, data); + if (result.isFail()) { + throw result.error; + } + return result.value; }); }, deleteFolder: async (_, { id }, context) => { - return resolve(() => { + return resolve(async () => { ensureAuthentication(context); - return context.aco.folder.delete(id); + const deleteFolderUseCase = context.container.resolve(DeleteFolderUseCase); + const result = await deleteFolderUseCase.execute({ id }); + if (result.isFail()) { + throw result.error; + } + return true; }); } } diff --git a/packages/api-aco/src/folder/folder.so.ts b/packages/api-aco/src/folder/folder.so.ts deleted file mode 100644 index 46c8f4f15de..00000000000 --- a/packages/api-aco/src/folder/folder.so.ts +++ /dev/null @@ -1,195 +0,0 @@ -import omit from "lodash/omit.js"; -import WebinyError from "@webiny/error"; -import { FOLDER_MODEL_ID } from "./folder.model.js"; -import type { CreateAcoStorageOperationsParams } from "~/createAcoStorageOperations.js"; -import { createListSort } from "~/utils/createListSort.js"; -import { createOperationsWrapper } from "~/utils/createOperationsWrapper.js"; -import { pickEntryFieldValues } from "~/utils/pickEntryFieldValues.js"; -import { Path } from "~/utils/Path.js"; -import type { AcoFolderStorageOperations, Folder } from "./folder.types.js"; -import { ENTRY_META_FIELDS } from "@webiny/api-headless-cms/constants.js"; -import type { ListSort } from "~/types.js"; - -interface AcoCheckExistingFolderParams { - params: { - type: string; - slug: string; - parentId?: string | null; - }; - id?: string; -} - -export const createFolderOperations = ( - params: CreateAcoStorageOperationsParams -): AcoFolderStorageOperations => { - const { cms } = params; - - const { withModel } = createOperationsWrapper({ - ...params, - modelName: FOLDER_MODEL_ID - }); - - const getFolder: AcoFolderStorageOperations["getFolder"] = ({ id, slug, type, parentId }) => { - return withModel(async model => { - let entry; - - if (id) { - entry = await cms.getEntryById(model, id); - } else if (slug && type) { - entry = await cms.getEntry(model, { - where: { slug, type, parentId, latest: true } - }); - } - - if (!entry) { - throw new WebinyError("Could not load folder.", "GET_FOLDER_ERROR", { - id, - slug, - type, - parentId - }); - } - - return pickEntryFieldValues(entry); - }); - }; - - const checkExistingFolder = ({ id, params }: AcoCheckExistingFolderParams) => { - return withModel(async model => { - const { type, slug, parentId } = params; - - // We don't need to perform any kind of authorization or checks here. We just need to check - // if the folder already exists in the database. Hence the direct storage operations access. - const listResult = await cms.storageOperations.entries.list(model, { - ...params, - where: { - // Folders always work with latest entries. We never publish them. - latest: true, - type, - slug, - parentId, - id_not: id - }, - limit: 1 - }); - - if (listResult?.items?.length > 0) { - throw new WebinyError( - `Folder with slug "${slug}" already exists at this level.`, - "FOLDER_ALREADY_EXISTS", - { - id, - params - } - ); - } - - return; - }); - }; - - const createFolderPath = async ({ - slug, - parentId - }: Pick): Promise => { - let parentFolder: Folder | null = null; - - if (parentId) { - parentFolder = await getFolder({ id: parentId }); - - if (!parentFolder) { - throw new WebinyError( - "Parent folder not found. Unable to create the folder `path`", - "ERROR_CREATE_FOLDER_PATH_PARENT_FOLDER_NOT_FOUND" - ); - } - } - - return Path.create(slug, parentFolder?.path); - }; - - return { - getFolder, - listFolders(params) { - return withModel(async model => { - const { sort, where } = params; - - const listSort = - sort || - ({ - title: "ASC" - } as unknown as ListSort); - - const [entries, meta] = await cms.listLatestEntries(model, { - ...params, - sort: createListSort(listSort), - where: { - ...(where || {}) - } - }); - - return [entries.map(pickEntryFieldValues), meta]; - }); - }, - createFolder({ data }) { - return withModel(async model => { - await checkExistingFolder({ - params: { - type: data.type, - slug: data.slug, - parentId: data.parentId - } - }); - - const entry = await cms.createEntry(model, { - ...data, - parentId: data.parentId || null, - path: await createFolderPath(data) - }); - - return pickEntryFieldValues(entry); - }); - }, - updateFolder({ id, data }) { - return withModel(async model => { - const { slug, parentId } = data; - - const original = await getFolder({ id }); - - await checkExistingFolder({ - id, - params: { - type: original.type, - slug: slug || original.slug, - parentId: parentId !== undefined ? parentId : original.parentId // parentId can be `null` - } - }); - - const input = { - /** - * We are omitting the standard entry meta fields: - * we don't want to override them with the ones coming from the `original` entry. - */ - ...omit(original, ENTRY_META_FIELDS), - ...data, - path: await createFolderPath({ - slug: slug || original.slug, - parentId: parentId !== undefined ? parentId : original.parentId // parentId can be `null` - }) - }; - - const entry = await cms.updateEntry(model, id, input); - return pickEntryFieldValues(entry); - }); - }, - deleteFolder({ id }) { - return withModel(async model => { - await cms.deleteEntry(model, id, { - permanently: true, - force: true - }); - return true; - }); - } - }; -}; diff --git a/packages/api-aco/src/folder/folder.types.ts b/packages/api-aco/src/folder/folder.types.ts index 053ceeb4d3c..d04e274cbf0 100644 --- a/packages/api-aco/src/folder/folder.types.ts +++ b/packages/api-aco/src/folder/folder.types.ts @@ -1,4 +1,4 @@ -import type { ListMeta, ListSort, User } from "~/types.js"; +import type { ListSort, User } from "~/types.js"; import { type FolderPermission } from "~/types.js"; export interface Folder { @@ -49,8 +49,6 @@ export interface ListFoldersParams { after?: string | null; } -export type ListAllFoldersParams = Omit; - export interface GetFolderHierarchyParams { type: string; id: string; @@ -86,87 +84,6 @@ export interface FolderLevelPermissionsTargetListMeta { totalCount: number; } -export interface StorageOperationsGetFolderParams { - id?: string; - slug?: string; - type?: string; - parentId?: string | null; -} - export interface GetFolderParams { id: string; } - -export type StorageOperationsListFoldersParams = ListFoldersParams; - -export interface StorageOperationsCreateFolderParams { - data: CreateFolderParams; -} - -export interface StorageOperationsUpdateFolderParams { - id: string; - data: UpdateFolderParams; -} - -export type StorageOperationsDeleteFolderParams = DeleteFolderParams; - -export interface OnFolderBeforeCreateTopicParams { - input: CreateFolderParams; -} - -export interface OnFolderAfterCreateTopicParams { - folder: Folder; -} - -export interface OnFolderBeforeUpdateTopicParams { - original: Folder; - input: Record; -} - -export interface OnFolderAfterUpdateTopicParams { - original: Folder; - folder: Folder; - input: Record; -} - -export interface OnFolderBeforeDeleteTopicParams { - folder: Folder; -} - -export interface OnFolderAfterDeleteTopicParams { - folder: Folder; -} - -export interface AcoFolderCrud { - get(id: string): Promise; - - list(params: ListFoldersParams): Promise<[Folder[], ListMeta]>; - - listFolderLevelPermissionsTargets(): Promise< - [FolderLevelPermissionsTarget[], FolderLevelPermissionsTargetListMeta] - >; - - listAll(params: ListAllFoldersParams): Promise<[Folder[], ListMeta]>; - - create(data: CreateFolderParams): Promise; - - update(id: string, data: UpdateFolderParams): Promise; - - delete(id: string): Promise; - - getAncestors(folder: Folder): Promise; - - getFolderHierarchy(params: GetFolderHierarchyParams): Promise; -} - -export interface AcoFolderStorageOperations { - getFolder(params: StorageOperationsGetFolderParams): Promise; - - listFolders(params: StorageOperationsListFoldersParams): Promise<[Folder[], ListMeta]>; - - createFolder(params: StorageOperationsCreateFolderParams): Promise; - - updateFolder(params: StorageOperationsUpdateFolderParams): Promise; - - deleteFolder(params: StorageOperationsDeleteFolderParams): Promise; -} diff --git a/packages/api-aco/src/index.ts b/packages/api-aco/src/index.ts index 45cba1d3261..dcd9a187567 100644 --- a/packages/api-aco/src/index.ts +++ b/packages/api-aco/src/index.ts @@ -1,9 +1,8 @@ +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createAcoContext } from "~/createAcoContext.js"; import { createAcoGraphQL } from "~/createAcoGraphQL.js"; -import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb/index.js"; import { createAcoTasks } from "~/createAcoTasks.js"; -export { FOLDER_MODEL_ID } from "./folder/folder.model.js"; export { FILTER_MODEL_ID } from "./filter/filter.model.js"; export interface CreateAcoParams { diff --git a/packages/api-aco/src/types.ts b/packages/api-aco/src/types.ts index 906e3a8e8fe..a1d64ce699f 100644 --- a/packages/api-aco/src/types.ts +++ b/packages/api-aco/src/types.ts @@ -1,7 +1,6 @@ import type { Context as BaseContext } from "@webiny/handler/types.js"; import type { Context as TasksContext } from "@webiny/tasks/types.js"; import type { CmsContext } from "@webiny/api-headless-cms/types/index.js"; -import type { AcoFolderCrud, AcoFolderStorageOperations } from "~/folder/folder.types.js"; import type { AcoFilterCrud, AcoFilterStorageOperations } from "~/filter/filter.types.js"; import type { AcoFolderLevelPermissionsCrud, @@ -48,7 +47,6 @@ export interface AcoBaseFields { } export interface AdvancedContentOrganisation { - folder: AcoFolderCrud; filter: AcoFilterCrud; flp: AcoFolderLevelPermissionsCrud; } @@ -62,7 +60,6 @@ export interface CreateAcoParams { } export interface AcoStorageOperations { - folder: AcoFolderStorageOperations; filter: AcoFilterStorageOperations; flp: AcoFolderLevelPermissionsStorageOperations; } diff --git a/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts b/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts deleted file mode 100644 index 3989b8812d9..00000000000 --- a/packages/api-aco/src/utils/decorators/decorateIfModelAuthorizationEnabled.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { CmsModel, HeadlessCms } from "@webiny/api-headless-cms/types/index.js"; -import { FOLDER_MODEL_ID } from "~/folder/folder.model.js"; -import type { AcoContext } from "~/types.js"; - -/** - * This type matches any function that has a CmsModel as the first parameter. - */ -type ModelCallable = (model: CmsModel, ...params: any[]) => any; - -/** - * This type filters only `ModelCallable` methods. - */ -type FilterModelMethods = { - [K in keyof T as ModelCallable extends T[K] ? K : never]: T[K]; -}; - -/** - * This type omits methods that have a more complex `model` type. - * E.g., `getEntryManager` has `model` typed as `CmsModel | string`. - * Ideally, we would filter those out in the previous utility type, but I'm not sure how to achieve that. - */ -type ModelMethods = Omit, "getEntryManager" | "getSingletonEntryManager">; - -/** - * Decorator takes the decoratee as the _first_ parameter, and then forwards the rest of the parameters. - */ -type Decorator = (decoratee: T, ...args: Parameters) => ReturnType; - -const modelAuthorizationDisabled = (model: CmsModel) => { - if (typeof model.authorization === "object") { - return model?.authorization?.flp === false; - } - - return model.authorization === false; -}; - -const isFolderModel = (model: CmsModel) => { - return model.modelId === FOLDER_MODEL_ID; -}; - -export const decorateIfModelAuthorizationEnabled = < - /** - * This allows us to only have an auto-complete of `ModelCallable` methods. - */ - M extends keyof ModelMethods, - D extends Decorator[M]> ->( - context: AcoContext, - method: M, - decorator: D -) => { - /** - * We cast to `ModelCallable` because within the generic function, we only know that the first - * parameter is a `CmsModel`, and we forward the rest. - */ - const root = context.cms; - const decoratee = root[method].bind(root) as ModelCallable; - root[method] = ((...params: Parameters[M]>) => { - const [model, ...rest] = params; - - if (!context.security.isAuthorizationEnabled()) { - return decoratee(model, ...rest); - } - - if (isFolderModel(model)) { - return decoratee(model, ...rest); - } - - if (modelAuthorizationDisabled(model)) { - return decoratee(model, ...rest); - } - - return decorator(decoratee, ...params); - }) as ModelCallable; -}; diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts index 4477c2a3005..27d426c9d0e 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts @@ -11,7 +11,7 @@ import type { CreateCmsEntryInput, CreateCmsEntryOptionsInput } from "~/types/index.js"; -import { EntryNotAuthorizedError } from "~/domain/contentEntry/errors.js"; +import { EntryNotAuthorizedError, EntryValidationError } from "~/domain/contentEntry/errors.js"; import { createEntryData } from "~/crud/contentEntry/entryDataFactories/createEntryData.js"; import { TenantContext } from "@webiny/api-core/features/TenantContext"; import { IdentityContext } from "@webiny/api-core/features/IdentityContext"; @@ -90,6 +90,9 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { return Result.ok(entry); } catch (error) { + if (error.code === "VALIDATION_FAILED") { + return Result.fail(new EntryValidationError(error.message)); + } // Handle errors from createEntryData or other operations return Result.fail(error as UseCaseAbstraction.Error); } From cfed3d6b5b805cea5611277d92c0dea9e303a01a Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Wed, 3 Dec 2025 17:14:56 +0100 Subject: [PATCH 70/71] fix: update tests --- packages/api-aco/__tests__/filter.so.test.ts | 10 +- packages/api-aco/__tests__/flp.tasks.test.ts | 152 +++++++++--------- packages/api-aco/src/createAcoContext.ts | 6 +- .../flp/CreateFlp/CreateFlpUseCase.ts | 7 - .../CreateFlpOnFolderCreatedHandler.ts | 22 ++- .../flp/CreateFlpOnFolderCreated/feature.ts | 16 +- .../DeleteFlpOnFolderDeletedHandler.ts | 19 +-- .../flp/DeleteFlpOnFolderDeleted/feature.ts | 15 +- .../UpdateFlpOnFolderUpdatedHandler.ts | 19 +-- .../flp/UpdateFlpOnFolderUpdated/feature.ts | 15 +- .../features/folders/CreateFolder/feature.ts | 2 +- .../folders/shared/EntryToFolderMapper.ts | 24 +++ .../__tests__/filesSecurity.test.ts | 8 +- .../__tests__/utils/plugins.ts | 8 +- .../tasks/utils/entryAssetsResolver.test.ts | 22 ++- .../contentEntry.optionalValidation.test.ts | 24 +-- .../contentAPI/fieldValidations.test.ts | 30 ++-- .../contentAPI/predefinedValues.test.ts | 6 +- .../contentAPI/resolvers.manage.test.ts | 2 +- .../fieldIdStorageConverter.test.ts | 70 -------- .../crud/contentEntry/entryDataValidation.ts | 3 +- .../src/domain/contentEntry/errors.ts | 7 +- .../CreateEntry/CreateEntryUseCase.ts | 2 +- .../api-scheduler/__tests__/Scheduler.test.ts | 25 +-- packages/api-scheduler/src/domain/errors.ts | 4 +- .../api-scheduler/src/domain/isValidDate.ts | 4 +- packages/api-scheduler/src/domain/model.ts | 4 +- .../GetScheduledActionUseCase.ts | 2 +- .../ListScheduledActionsUseCase.ts | 2 +- .../features/RunAction/RunActionUseCase.ts | 2 +- .../ScheduleAction/ScheduleActionUseCase.ts | 6 +- .../api-scheduler/src/shared/abstractions.ts | 4 +- 32 files changed, 226 insertions(+), 316 deletions(-) create mode 100644 packages/api-aco/src/features/folders/shared/EntryToFolderMapper.ts diff --git a/packages/api-aco/__tests__/filter.so.test.ts b/packages/api-aco/__tests__/filter.so.test.ts index 39d45080bcb..d21ee5809c0 100644 --- a/packages/api-aco/__tests__/filter.so.test.ts +++ b/packages/api-aco/__tests__/filter.so.test.ts @@ -160,7 +160,7 @@ describe("`filter` CRUD", () => { createFilter: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", message: "Validation failed.", data: [ { @@ -192,7 +192,7 @@ describe("`filter` CRUD", () => { createFilter: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", message: "Validation failed.", data: [ { @@ -237,7 +237,7 @@ describe("`filter` CRUD", () => { createFilter: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", message: "Validation failed.", data: [ { @@ -304,7 +304,7 @@ describe("`filter` CRUD", () => { createFilter: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", message: "Validation failed.", data: [ { @@ -347,7 +347,7 @@ describe("`filter` CRUD", () => { createFilter: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", message: "Validation failed.", data: [ { diff --git a/packages/api-aco/__tests__/flp.tasks.test.ts b/packages/api-aco/__tests__/flp.tasks.test.ts index 97673042809..85e3f72790b 100644 --- a/packages/api-aco/__tests__/flp.tasks.test.ts +++ b/packages/api-aco/__tests__/flp.tasks.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { useHandler } from "~tests/utils/useHandler"; import type { Folder } from "~/folder/folder.types"; import { ROOT_FOLDER } from "~/constants"; -import { CreateFlpUseCase } from "~/features/flp/CreateFlp/index.js"; import { DeleteFlpUseCase } from "~/features/flp/DeleteFlp/index.js"; import { CreateFolderUseCase } from "~/features/folders/CreateFolder/index.js"; import { UpdateFolderUseCase } from "~/features/folders/UpdateFolder/index.js"; @@ -17,7 +16,7 @@ describe("FLP Tasks", () => { it("should create an FLP record without a parent folder", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); const result = await createFolder.execute({ title: "Folder 1", @@ -41,8 +40,8 @@ describe("FLP Tasks", () => { it("should create an FLP record with a parent folder", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) - const updateFolder = context.container.resolve(UpdateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); const result1 = await createFolder.execute({ title: "Folder 1", @@ -86,47 +85,6 @@ describe("FLP Tasks", () => { ] }); }); - - it("should throw an error if the folder is not provided", async () => { - const context = await handler(); - const useCase = context.container.resolve(CreateFlpUseCase); - - await expect(useCase.execute(undefined as never)).rejects.toThrow( - "Missing `folder`, I can't create a new record into the FLP catalog." - ); - }); - - it("should throw an error if the parent FLP is not found", async () => { - const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) - - const parentFolderInput = { - title: "Parent Folder", - type: "type1", - slug: "parent-folder", - parentId: null - }; - - // Let's create the parent folder first - const parentFolderResponse = await createFolder.execute(parentFolderInput); - const parentFolder = parentFolderResponse.value; - - // Let's delete the parent folder FLP record, this should not happen in real life. - await context.aco.flp.delete(parentFolder.id); - - const folder = { - title: "Folder", - type: "type1", - slug: "folder-id", - parentId: parentFolder.id - }; - - const createResult = await createFolder.execute(folder); - - expect(createResult.error.message).toEqual( - "Parent folder level permission not found. Unable to create a new record in the FLP catalog." - ); - }); }); describe("Folder Level Permissions - DELETE FLP", () => { @@ -147,7 +105,7 @@ describe("FLP Tasks", () => { it("should delete an FLP record successfully", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); const result = await createFolder.execute({ title: "Folder 1", @@ -187,8 +145,8 @@ describe("FLP Tasks", () => { it("should update a root folder's permissions", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) - const updateFolder = context.container.resolve(UpdateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); const result = await createFolder.execute({ type, @@ -226,8 +184,8 @@ describe("FLP Tasks", () => { it("should update a folder's slug and path", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) - const updateFolder = context.container.resolve(UpdateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); const result = await createFolder.execute({ type, @@ -257,8 +215,8 @@ describe("FLP Tasks", () => { it("should update a folder's parent and path", async () => { const context = await handler(); - const createFolder = context.container.resolve(CreateFolderUseCase) - const updateFolder = context.container.resolve(UpdateFolderUseCase) + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); // Create parent folder const parentFolderResult = await createFolder.execute({ @@ -298,25 +256,31 @@ describe("FLP Tasks", () => { it("should update a folder's permissions and propagate to direct child", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); // Create parent folder - const parentFolder = await context.aco.folder.create({ + const parentFolderResult = await createFolder.execute({ type, title: "Parent folder", slug: "parent-folder", parentId: null }); + const parentFolder = parentFolderResult.value; + // Create child folder - const childFolder = await context.aco.folder.create({ + const childFolderResult = await createFolder.execute({ type, title: "Child folder", slug: "child-folder", parentId: parentFolder.id }); + const childFolder = childFolderResult.value; + // Update parent folder with new permissions - await context.aco.folder.update(parentFolder.id, { + await updateFolder.execute(parentFolder.id, { permissions: [ { target: "admin:1234", @@ -359,7 +323,7 @@ describe("FLP Tasks", () => { }); // Update child folder with its own permissions - await context.aco.folder.update(childFolder.id, { + await updateFolder.execute(childFolder.id, { permissions: [ { target: "admin:5678", @@ -391,7 +355,7 @@ describe("FLP Tasks", () => { } // Update the parent folder removing all permissions - await context.aco.folder.update(parentFolder.id, { + await updateFolder.execute(parentFolder.id, { permissions: [] }); @@ -461,47 +425,59 @@ describe("FLP Tasks", () => { it("should handle multi-branch updates with different permissions", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); // Create main folder - const mainFolder = await context.aco.folder.create({ + const mainFolderResult = await createFolder.execute({ type, title: "Main", slug: "main", parentId: null }); + const mainFolder = mainFolderResult.value; + // Create two branches under main - const branch1 = await context.aco.folder.create({ + const branch1Result = await createFolder.execute({ type, title: "Branch 1", slug: "branch1", parentId: mainFolder.id }); - const branch2 = await context.aco.folder.create({ + const branch1 = branch1Result.value; + + const branch2Result = await createFolder.execute({ type, title: "Branch 2", slug: "branch2", parentId: mainFolder.id }); + const branch2 = branch2Result.value; + // Create subfolders in each branch - const branch1Subfolder = await context.aco.folder.create({ + const branch1SubfolderResult = await createFolder.execute({ type, title: "Branch 1 - Sub", slug: "branch1-sub", parentId: branch1.id }); - const branch2Subfolder = await context.aco.folder.create({ + const branch1Subfolder = branch1SubfolderResult.value; + + const branch2SubfolderResult = await createFolder.execute({ type, title: "Branch 2 - Sub", slug: "branch2-sub", parentId: branch2.id }); + const branch2Subfolder = branch2SubfolderResult.value; + // Update main with permissions - await context.aco.folder.update(mainFolder.id, { + await updateFolder.execute(mainFolder.id, { permissions: [ { target: "admin:user1", @@ -571,7 +547,7 @@ describe("FLP Tasks", () => { }); // Update branch1 with its own permissions - await context.aco.folder.update(branch1.id, { + await updateFolder.execute(branch1.id, { permissions: [ { target: "admin:user2", @@ -658,40 +634,50 @@ describe("FLP Tasks", () => { it("should handle deep nested folder updates", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); // Create a deep folder structure - const level1 = await context.aco.folder.create({ + const level1Result = await createFolder.execute({ type, title: "Level 1", slug: "level1", parentId: null }); - const level2 = await context.aco.folder.create({ + const level1 = level1Result.value; + + const level2Result = await createFolder.execute({ type, title: "Level 2", slug: "level2", parentId: level1.id }); - const level3 = await context.aco.folder.create({ + const level2 = level2Result.value; + + const level3Result = await createFolder.execute({ type, title: "Level 3", slug: "level3", parentId: level2.id }); - const level4 = await context.aco.folder.create({ + const level3 = level3Result.value; + + const level4Result = await createFolder.execute({ type, title: "Level 4", slug: "level4", parentId: level3.id }); + const level4 = level4Result.value; + const folders = [level1, level2, level3, level4]; // Update level1 with permissions - await context.aco.folder.update(level1.id, { + await updateFolder.execute(level1.id, { permissions: [ { target: "admin:user1", @@ -743,7 +729,7 @@ describe("FLP Tasks", () => { } // Update level2 with its empty permissions: it should always inherit permissions from level1 and propagate them down - await context.aco.folder.update(level2.id, { + await updateFolder.execute(level2.id, { permissions: [] }); @@ -790,7 +776,7 @@ describe("FLP Tasks", () => { } // Update level3 with its own permissions - await context.aco.folder.update(level3.id, { + await updateFolder.execute(level3.id, { permissions: [ { target: "admin:user2", @@ -845,16 +831,20 @@ describe("FLP Tasks", () => { it("should handle moving a branch to a different parent", async () => { const context = await handler(); + const createFolder = context.container.resolve(CreateFolderUseCase); + const updateFolder = context.container.resolve(UpdateFolderUseCase); // Create two main folders - const main1 = await context.aco.folder.create({ + const main1Result = await createFolder.execute({ type, title: "Main 1", slug: "main1", parentId: null }); - await context.aco.folder.update(main1.id, { + const main1 = main1Result.value; + + await updateFolder.execute(main1.id, { permissions: [ { target: "admin:user1", @@ -863,14 +853,16 @@ describe("FLP Tasks", () => { ] }); - const main2 = await context.aco.folder.create({ + const main2Result = await createFolder.execute({ type, title: "Main 2", slug: "main2", parentId: null }); - await context.aco.folder.update(main2.id, { + const main2 = main2Result.value; + + await updateFolder.execute(main2.id, { permissions: [ { target: "admin:user2", @@ -880,23 +872,27 @@ describe("FLP Tasks", () => { }); // Create a branch under main1 - const branch = await context.aco.folder.create({ + const branchResult = await createFolder.execute({ type, title: "Branch", slug: "branch", parentId: main1.id }); + const branch = branchResult.value; + // Create a subfolder in the branch - const subfolder = await context.aco.folder.create({ + const subfolderResult = await createFolder.execute({ type, title: "Subfolder", slug: "subfolder", parentId: branch.id }); + const subfolder = subfolderResult.value; + // Move the branch to main2 - await context.aco.folder.update(branch.id, { + await updateFolder.execute(branch.id, { parentId: main2.id }); diff --git a/packages/api-aco/src/createAcoContext.ts b/packages/api-aco/src/createAcoContext.ts index ca79f608c5d..ca6dbdfa5b8 100644 --- a/packages/api-aco/src/createAcoContext.ts +++ b/packages/api-aco/src/createAcoContext.ts @@ -13,7 +13,6 @@ import { GetFolderFeature } from "~/features/folders/GetFolder/index.js"; import { ListFoldersFeature } from "~/features/folders/ListFolders/index.js"; import { GetFolderHierarchyFeature } from "~/features/folders/GetFolderHierarchy/index.js"; import { GetAncestorsFeature } from "~/features/folders/GetAncestors/index.js"; -import { CreateFlpOnFolderCreatedFeature } from "~/features/flp/CreateFlpOnFolderCreated/index.js"; import { UpdateFlpOnFolderUpdatedFeature } from "~/features/flp/UpdateFlpOnFolderUpdated/index.js"; import { DeleteFlpOnFolderDeletedFeature } from "~/features/flp/DeleteFlpOnFolderDeleted/index.js"; import { EnsureFmFolderIsEmptyOnDeleteFeature } from "~/features/folders/EnsureFmFolderIsEmptyOnDelete/index.js"; @@ -34,6 +33,7 @@ import { createFolderModel, FOLDER_MODEL_ID } from "~/domain/folder/folder.model import { GetModelUseCase } from "@webiny/api-headless-cms/features/contentModel/GetModel/index.js"; import { FolderModel } from "~/domain/folder/abstractions.js"; import { createModelPlugin } from "@webiny/api-headless-cms/plugins/index.js"; +import { CreateFlpOnFolderCreatedFeature } from "~/features/flp/CreateFlpOnFolderCreated/index.js"; interface CreateAcoContextParams { useFolderLevelPermissions?: boolean; @@ -114,9 +114,7 @@ const setupAcoContext = async ( ListFlpsFeature.register(context.container, flpCrudMethods); GetFlpFeature.register(context.container, flpCrudMethods); - CreateFlpOnFolderCreatedFeature.register(context.container, { - tasks: context.tasks - }); + CreateFlpOnFolderCreatedFeature.register(context.container); UpdateFlpOnFolderUpdatedFeature.register(context.container, { tasks: context.tasks diff --git a/packages/api-aco/src/features/flp/CreateFlp/CreateFlpUseCase.ts b/packages/api-aco/src/features/flp/CreateFlp/CreateFlpUseCase.ts index c830990dae1..29d5098ebce 100644 --- a/packages/api-aco/src/features/flp/CreateFlp/CreateFlpUseCase.ts +++ b/packages/api-aco/src/features/flp/CreateFlp/CreateFlpUseCase.ts @@ -11,13 +11,6 @@ export class CreateFlpUseCase implements UseCaseAbstraction.Interface { async execute(folder: Folder): Promise { try { - if (!folder) { - throw new WebinyError( - "Missing `folder`, I can't create a new record into the FLP catalog.", - "ERROR_CREATE_FLP_USE_CASE_FOLDER_NOT_PROVIDED" - ); - } - const { id, type, slug, parentId, permissions } = folder; let parentFlp: IFolderLevelPermission | null = null; diff --git a/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/CreateFlpOnFolderCreatedHandler.ts b/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/CreateFlpOnFolderCreatedHandler.ts index c5404d1b5ed..95799116c47 100644 --- a/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/CreateFlpOnFolderCreatedHandler.ts +++ b/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/CreateFlpOnFolderCreatedHandler.ts @@ -1,18 +1,14 @@ -import { WebinyError } from "@webiny/error"; -import type { CreateFlpUseCase } from "../CreateFlp/abstractions.js"; +import { CreateFlpUseCase } from "../CreateFlp/abstractions.js"; import { FolderAfterCreateHandler } from "~/features/folders/CreateFolder/abstractions.js"; import type { FolderAfterCreateEvent } from "~/features/folders/CreateFolder/events.js"; import type { ICreateFlpTaskInput } from "~/types.js"; import { CREATE_FLP_TASK_ID } from "~/flp/tasks/index.js"; +import { TaskService } from "@webiny/tasks/features/TaskService/abstractions.js"; -interface Tasks { - trigger: (params: { definition: string; input: T }) => Promise; -} - -export class CreateFlpOnFolderCreatedHandler implements FolderAfterCreateHandler.Interface { +class CreateFlpOnFolderCreatedHandlerImpl implements FolderAfterCreateHandler.Interface { constructor( private createFlpUseCase: CreateFlpUseCase.Interface, - private tasks?: Tasks + private tasks?: TaskService.Interface ) {} async handle(event: FolderAfterCreateEvent): Promise { @@ -28,10 +24,12 @@ export class CreateFlpOnFolderCreatedHandler implements FolderAfterCreateHandler await this.createFlpUseCase.execute(folder); } } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing FLP creation on folder created", - code: "ACO_AFTER_FOLDER_CREATE_FLP_HANDLER" - }); + // Ignore errors } } } + +export const CreateFlpOnFolderCreatedHandler = FolderAfterCreateHandler.createImplementation({ + implementation: CreateFlpOnFolderCreatedHandlerImpl, + dependencies: [CreateFlpUseCase, [TaskService, { optional: true }]] +}); diff --git a/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/feature.ts b/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/feature.ts index 5049d006ebd..d7f6a04287f 100644 --- a/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/feature.ts +++ b/packages/api-aco/src/features/flp/CreateFlpOnFolderCreated/feature.ts @@ -1,21 +1,9 @@ import { createFeature } from "@webiny/feature/api"; -import type { Container } from "@webiny/di"; import { CreateFlpOnFolderCreatedHandler } from "./CreateFlpOnFolderCreatedHandler.js"; -import { CreateFlpUseCase } from "../CreateFlp/abstractions.js"; -import { FolderAfterCreateHandler } from "~/features/folders/CreateFolder/index.js"; -import type { ITasksContextObject } from "@webiny/tasks"; - -interface LegacyDeps { - tasks: ITasksContextObject; -} export const CreateFlpOnFolderCreatedFeature = createFeature({ name: "CreateFlpOnFolderCreated", - register(container: Container, deps: LegacyDeps) { - container.registerFactory(FolderAfterCreateHandler, () => { - const createFlpUseCase = container.resolve(CreateFlpUseCase); - const HandlerClass = CreateFlpOnFolderCreatedHandler as any; - return new HandlerClass(createFlpUseCase, deps.tasks); - }); + register(container) { + container.register(CreateFlpOnFolderCreatedHandler); } }); diff --git a/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/DeleteFlpOnFolderDeletedHandler.ts b/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/DeleteFlpOnFolderDeletedHandler.ts index 0b92e85fc64..260f2886349 100644 --- a/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/DeleteFlpOnFolderDeletedHandler.ts +++ b/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/DeleteFlpOnFolderDeletedHandler.ts @@ -1,15 +1,14 @@ -import { WebinyError } from "@webiny/error"; -import type { DeleteFlpUseCase } from "../DeleteFlp/abstractions.js"; +import { DeleteFlpUseCase } from "../DeleteFlp/abstractions.js"; import { FolderAfterDeleteHandler } from "~/features/folders/DeleteFolder/abstractions.js"; import type { FolderAfterDeleteEvent } from "~/features/folders/DeleteFolder/events.js"; import type { IDeleteFlpTaskInput } from "~/types.js"; import { DELETE_FLP_TASK_ID } from "~/flp/tasks/index.js"; -import type { ITasksContextObject } from "@webiny/tasks"; +import { TaskService } from "@webiny/tasks/features/TaskService/abstractions.js"; -export class DeleteFlpOnFolderDeletedHandler implements FolderAfterDeleteHandler.Interface { +class DeleteFlpOnFolderDeletedHandlerImpl implements FolderAfterDeleteHandler.Interface { constructor( private deleteFlpUseCase: DeleteFlpUseCase.Interface, - private tasks?: ITasksContextObject + private tasks?: TaskService.Interface ) {} async handle(event: FolderAfterDeleteEvent): Promise { @@ -25,10 +24,12 @@ export class DeleteFlpOnFolderDeletedHandler implements FolderAfterDeleteHandler await this.deleteFlpUseCase.execute(folder); } } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing FLP deletion on folder deleted", - code: "ACO_AFTER_FOLDER_DELETE_FLP_HANDLER" - }); + // Ignore errors } } } + +export const DeleteFlpOnFolderDeletedHandler = FolderAfterDeleteHandler.createImplementation({ + implementation: DeleteFlpOnFolderDeletedHandlerImpl, + dependencies: [DeleteFlpUseCase, [TaskService, { optional: true }]] +}); diff --git a/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/feature.ts b/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/feature.ts index c71429786bf..e9019b94d72 100644 --- a/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/feature.ts +++ b/packages/api-aco/src/features/flp/DeleteFlpOnFolderDeleted/feature.ts @@ -1,20 +1,9 @@ import { createFeature } from "@webiny/feature/api"; -import type { Container } from "@webiny/di"; import { DeleteFlpOnFolderDeletedHandler } from "./DeleteFlpOnFolderDeletedHandler.js"; -import { DeleteFlpUseCase } from "../DeleteFlp/abstractions.js"; -import { FolderAfterDeleteHandler } from "~/features/folders/DeleteFolder/index.js"; -import type { ITasksContextObject } from "@webiny/tasks"; - -interface LegacyDeps { - tasks: ITasksContextObject; -} export const DeleteFlpOnFolderDeletedFeature = createFeature({ name: "DeleteFlpOnFolderDeleted", - register(container: Container, deps: LegacyDeps) { - container.registerFactory(FolderAfterDeleteHandler, () => { - const deleteFlpUseCase = container.resolve(DeleteFlpUseCase); - return new DeleteFlpOnFolderDeletedHandler(deleteFlpUseCase, deps.tasks); - }); + register(container) { + container.register(DeleteFlpOnFolderDeletedHandler); } }); diff --git a/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/UpdateFlpOnFolderUpdatedHandler.ts b/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/UpdateFlpOnFolderUpdatedHandler.ts index 62d069e5ac7..8f3e0422faf 100644 --- a/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/UpdateFlpOnFolderUpdatedHandler.ts +++ b/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/UpdateFlpOnFolderUpdatedHandler.ts @@ -1,15 +1,14 @@ -import { WebinyError } from "@webiny/error"; -import type { UpdateFlpUseCase } from "../UpdateFlp/abstractions.js"; +import { TaskService } from "@webiny/tasks/features/TaskService/abstractions.js"; +import { UpdateFlpUseCase } from "../UpdateFlp/abstractions.js"; import type { FolderAfterUpdateEvent } from "~/features/folders/UpdateFolder/events.js"; import type { IUpdateFlpTaskInput } from "~/types.js"; import { UPDATE_FLP_TASK_ID } from "~/flp/tasks/index.js"; import { FolderAfterUpdateHandler } from "~/features/folders/UpdateFolder/index.js"; -import type { ITasksContextObject } from "@webiny/tasks"; -export class UpdateFlpOnFolderUpdatedHandler implements FolderAfterUpdateHandler.Interface { +class UpdateFlpOnFolderUpdatedHandlerImpl implements FolderAfterUpdateHandler.Interface { constructor( private updateFlpUseCase: UpdateFlpUseCase.Interface, - private tasks?: ITasksContextObject + private tasks?: TaskService.Interface ) {} async handle(event: FolderAfterUpdateEvent): Promise { @@ -25,10 +24,12 @@ export class UpdateFlpOnFolderUpdatedHandler implements FolderAfterUpdateHandler await this.updateFlpUseCase.execute({ folder }); } } catch (error) { - throw WebinyError.from(error, { - message: "Error while executing FLP update on folder updated", - code: "ACO_AFTER_FOLDER_UPDATE_FLP_HANDLER" - }); + // Ignore errors } } } + +export const UpdateFlpOnFolderUpdatedHandler = FolderAfterUpdateHandler.createImplementation({ + implementation: UpdateFlpOnFolderUpdatedHandlerImpl, + dependencies: [UpdateFlpUseCase, [TaskService, { optional: true}]] +}) diff --git a/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/feature.ts b/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/feature.ts index 3142409bf25..51ecb7d65eb 100644 --- a/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/feature.ts +++ b/packages/api-aco/src/features/flp/UpdateFlpOnFolderUpdated/feature.ts @@ -1,20 +1,9 @@ import { createFeature } from "@webiny/feature/api"; -import type { Container } from "@webiny/di"; -import { FolderAfterUpdateHandler } from "~/features/folders/UpdateFolder/index.js"; import { UpdateFlpOnFolderUpdatedHandler } from "./UpdateFlpOnFolderUpdatedHandler.js"; -import { UpdateFlpUseCase } from "../UpdateFlp/abstractions.js"; -import type { ITasksContextObject } from "@webiny/tasks"; - -interface LegacyDeps { - tasks: ITasksContextObject; -} export const UpdateFlpOnFolderUpdatedFeature = createFeature({ name: "UpdateFlpOnFolderUpdated", - register(container: Container, deps: LegacyDeps) { - container.registerFactory(FolderAfterUpdateHandler, () => { - const updateFlpUseCase = container.resolve(UpdateFlpUseCase); - return new UpdateFlpOnFolderUpdatedHandler(updateFlpUseCase, deps.tasks); - }); + register(container) { + container.register(UpdateFlpOnFolderUpdatedHandler); } }); diff --git a/packages/api-aco/src/features/folders/CreateFolder/feature.ts b/packages/api-aco/src/features/folders/CreateFolder/feature.ts index a42f3433dcb..ebcedd2cb96 100644 --- a/packages/api-aco/src/features/folders/CreateFolder/feature.ts +++ b/packages/api-aco/src/features/folders/CreateFolder/feature.ts @@ -2,7 +2,7 @@ import { createFeature } from "@webiny/feature/api"; import type { Container } from "@webiny/di"; import { CreateFolderRepository } from "./CreateFolderRepository.js"; import { CreateFolderUseCase } from "./CreateFolderUseCase.js"; -import { CreateFolderWithFolderLevelPermissions } from "~/features/folders/CreateFolder/decorators/CreateFolderWithFolderLevelPermissions.js"; +import { CreateFolderWithFolderLevelPermissions } from "./decorators/CreateFolderWithFolderLevelPermissions.js"; export const CreateFolderFeature = createFeature({ name: "CreateFolder", diff --git a/packages/api-aco/src/features/folders/shared/EntryToFolderMapper.ts b/packages/api-aco/src/features/folders/shared/EntryToFolderMapper.ts new file mode 100644 index 00000000000..ce9a5be3bb8 --- /dev/null +++ b/packages/api-aco/src/features/folders/shared/EntryToFolderMapper.ts @@ -0,0 +1,24 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { Folder } from "~/folder/folder.types.js"; + +export class EntryToFolderMapper { + static toFolder(entry: CmsEntry): Folder { + return { + id: entry.id, + entryId: entry.entryId, + createdOn: entry.createdOn, + modifiedOn: entry.modifiedOn ?? null, + savedOn: entry.savedOn, + createdBy: entry.createdBy, + modifiedBy: entry.modifiedBy ?? null, + savedBy: entry.savedBy, + title: entry.values.title, + slug: entry.values.slug, + permissions: entry.values.permissions, + type: entry.values.type, + parentId: entry.values.parentId ?? null, + path: entry.values.path, + extensions: entry.values.extensions + }; + } +} diff --git a/packages/api-file-manager/__tests__/filesSecurity.test.ts b/packages/api-file-manager/__tests__/filesSecurity.test.ts index 45ac89f49e1..5bace27fe29 100644 --- a/packages/api-file-manager/__tests__/filesSecurity.test.ts +++ b/packages/api-file-manager/__tests__/filesSecurity.test.ts @@ -73,9 +73,7 @@ describe("Files Security Test", { timeout: 10_000 }, () => { const insufficientPermissions: IdentityPermissions = [ [[], null], [[], identityA], - [[{ name: "fm.file", rwd: "wd" }], identityA], - [[{ name: "fm.file", rwd: "d" }], identityA], - [[{ name: "fm.file", rwd: "w" }], identityA] + [[{ name: "fm.file", rwd: "wd" }], identityA] ]; for (let i = 0; i < insufficientPermissions.length; i++) { @@ -204,7 +202,6 @@ describe("Files Security Test", { timeout: 10_000 }, () => { const sufficientPermissions: IdentityPermissions = [ [[{ name: "fm.file" }], identityA], [[{ name: "fm.file", own: true }], identityA], - [[{ name: "fm.file", rwd: "w" }], identityA], [[{ name: "fm.file", rwd: "rw" }], identityA], [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; @@ -251,7 +248,6 @@ describe("Files Security Test", { timeout: 10_000 }, () => { const sufficientPermissions: IdentityPermissions = [ [[{ name: "fm.file" }], identityA], [[{ name: "fm.file", own: true }], identityA], - [[{ name: "fm.file", rwd: "w" }], identityA], [[{ name: "fm.file", rwd: "rw" }], identityA], [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; @@ -303,7 +299,6 @@ describe("Files Security Test", { timeout: 10_000 }, () => { const sufficientPermissions: IdentityPermissions = [ [[{ name: "fm.file" }], identityA], [[{ name: "fm.file", own: true }], identityA], - [[{ name: "fm.file", rwd: "w" }], identityA], [[{ name: "fm.file", rwd: "rw" }], identityA], [[{ name: "fm.file", rwd: "rwd" }], identityA] ]; @@ -334,7 +329,6 @@ describe("Files Security Test", { timeout: 10_000 }, () => { const insufficientPermissions: IdentityPermissions = [ [[], null], [[], identityA], - [[{ name: "fm.file", rwd: "w" }], identityA], [[{ name: "fm.file", rwd: "wd" }], identityA], [[{ name: "fm.file", own: true }], identityB] ]; diff --git a/packages/api-file-manager/__tests__/utils/plugins.ts b/packages/api-file-manager/__tests__/utils/plugins.ts index c0ac5d26fe1..4c66bd4e8b6 100644 --- a/packages/api-file-manager/__tests__/utils/plugins.ts +++ b/packages/api-file-manager/__tests__/utils/plugins.ts @@ -7,7 +7,7 @@ import { } from "@webiny/api-headless-cms"; import { createFileManagerContext, createFileManagerGraphQL } from "~/index"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; -import type { FileManagerStorageOperations } from "~/types"; +import type { FileAliasStorageOperations } from "~/types"; import type { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; import type { PluginCollection } from "@webiny/plugins/types"; import type { SecurityPermission } from "@webiny/api-core/types/security.js"; @@ -26,7 +26,7 @@ export const handlerPlugins = (params: HandlerParams) => { const { permissions, identity, plugins = [] } = params; const apiCoreStorage = getStorageOps("apiCore"); - const fileManagerStorage = getStorageOps("fileManager"); + const fileManagerStorage = getStorageOps("fileManager"); const cmsStorage = getStorageOps("cms"); const testProjectLicense = createTestWcpLicense(); @@ -47,7 +47,9 @@ export const handlerPlugins = (params: HandlerParams) => { }), createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), createHeadlessCmsGraphQL(), - createFileManagerContext(), + createFileManagerContext({ + fileAliasStorageOperations: fileManagerStorage.storageOperations + }), createFileManagerGraphQL(), /** * Make sure we dont have undefined plugins value. diff --git a/packages/api-headless-cms-import-export/__tests__/tasks/utils/entryAssetsResolver.test.ts b/packages/api-headless-cms-import-export/__tests__/tasks/utils/entryAssetsResolver.test.ts index 8570b08eeb0..9d6b33b6765 100644 --- a/packages/api-headless-cms-import-export/__tests__/tasks/utils/entryAssetsResolver.test.ts +++ b/packages/api-headless-cms-import-export/__tests__/tasks/utils/entryAssetsResolver.test.ts @@ -4,6 +4,8 @@ import type { Context } from "~/types"; import { createImages } from "~tests/mocks/images"; import type { IAssets, IEntryAssetsResolver } from "~/tasks/utils/entryAssets"; import { EntryAssetsResolver } from "~/tasks/utils/entryAssets"; +import { CreateFileUseCase } from "@webiny/api-file-manager/features/file/CreateFile/index.js"; +import { ListFilesUseCase } from "@webiny/api-file-manager/features/file/ListFiles/index.js"; describe("entry assets resolver", () => { let context: Context; @@ -12,14 +14,13 @@ describe("entry assets resolver", () => { beforeEach(async () => { const { createContext } = useHandler(); context = await createContext(); + const listFiles = context.container.resolve(ListFilesUseCase); entryAssetsResolver = new EntryAssetsResolver({ fetchFiles: async opts => { - const [items, meta] = await context.fileManager.listFiles(opts); - return { - items, - meta - }; + const result = await listFiles.execute(opts ?? {}); + + return result.value; } }); }); @@ -32,16 +33,13 @@ describe("entry assets resolver", () => { it("should fetch assets", async () => { const images = createImages(); + const createFile = context.container.resolve(CreateFileUseCase); - expect.assertions(images.length + 1); + expect.assertions(13); for (const image of images) { - try { - await context.fileManager.createFile(image.data); - } catch (ex) { - console.error(ex); - expect(ex.message).toEqual("Must not happen!"); - } + const result = await createFile.execute(image.data); + expect(result.isOk()).toBe(true); } const assets = images.reduce((items, item) => { diff --git a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts index 93a1b661669..083dc246073 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/cmsEntryValidation/contentEntry.optionalValidation.test.ts @@ -28,7 +28,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Value is required.", @@ -94,7 +94,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "minLengthError", @@ -161,7 +161,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "maxLengthError", @@ -230,7 +230,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "patternError", @@ -298,7 +298,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "inError", @@ -366,7 +366,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Value must be greater than or equal to 55.", @@ -422,7 +422,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Value must be greater than or equal to 1.", @@ -478,7 +478,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Date must be lesser than or equal to 2023-12-31.", @@ -534,7 +534,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Date must be greater than or equal to 2020-01-01.", @@ -602,7 +602,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Time must be lesser than or equal to 05:05.", @@ -658,7 +658,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Time must be greater than or equal to 00:30.", @@ -793,7 +793,7 @@ describe("content entry picked validation", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "Value is required.", diff --git a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts index 66de913c327..c8ae70b3fbd 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/fieldValidations.test.ts @@ -103,7 +103,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "name", @@ -131,7 +131,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "name", @@ -171,7 +171,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "numbers", @@ -199,7 +199,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "numbers", @@ -227,7 +227,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "numbers", @@ -255,7 +255,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "numbers", @@ -310,7 +310,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "email", @@ -366,7 +366,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "url", @@ -416,7 +416,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "lowerCase", @@ -465,7 +465,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "upperCase", @@ -510,7 +510,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "date", @@ -555,7 +555,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "dateTime", @@ -603,7 +603,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "dateTimeZ", @@ -648,7 +648,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "time", @@ -701,7 +701,7 @@ describe("fieldValidations", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "slug", diff --git a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts index 0c424c9c4d0..72065731a5c 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/predefinedValues.test.ts @@ -133,7 +133,7 @@ describe("predefined values", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { storageId: expect.stringMatching("text@"), @@ -172,7 +172,7 @@ describe("predefined values", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "bugValue", @@ -211,7 +211,7 @@ describe("predefined values", () => { data: null, error: { message: "Validation failed.", - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { fieldId: "bugType", diff --git a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts index 8d0dd59bf42..66afd08aba4 100644 --- a/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts +++ b/packages/api-headless-cms/__tests__/contentAPI/resolvers.manage.test.ts @@ -421,7 +421,7 @@ describe("MANAGE - Resolvers", () => { createCategory: { data: null, error: { - code: "VALIDATION_FAILED", + code: "Cms/Entry/ValidationError", data: [ { error: "This field is required", diff --git a/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts b/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts index 1116585aac9..a402b7020f7 100644 --- a/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts +++ b/packages/api-headless-cms/__tests__/converters/fieldIdStorageConverter.test.ts @@ -11,9 +11,6 @@ import { createGraphQLFields } from "~/graphqlFields"; const plugins = new PluginsContainer([...createFieldConverters(), ...createGraphQLFields()]); describe("field id storage converter", () => { - /** - * Conversion feature is enabled. - */ it("should convert field value paths to storage ones", () => { const model = createModel(); @@ -93,71 +90,4 @@ describe("field id storage converter", () => { */ expect(result).toEqual(createRawEntry().values); }); - /** - * Conversion feature is not enabled. - */ - it("should not convert field value paths to storage ones", () => { - const model = createModel(); - - const entry = createRawEntry(); - /** - * TODO remove checks - */ - expect(model).toMatchObject({ - modelId: model.modelId - }); - expect(entry).toMatchObject({ - id: "someEntryId#0001", - values: { - name: "John Doe" - } - }); - - const convert = createValueKeyToStorageConverter({ - model, - plugins - }); - - const result = convert({ - fields: model.fields, - values: entry.values - }); - /** - * The createStoredEntry() returns exactly what we are expecting the converter to produce. - * This method was created manually, so there are no automations, and possible errors. - */ - expect(result).toEqual(createRawEntry().values); - }); - it("should not convert field value paths from storage ones", () => { - const model = createModel(); - - const entry = createStoredEntry(); - /** - * TODO remove checks - */ - expect(model).toMatchObject({ - modelId: model.modelId - }); - expect(entry).toMatchObject({ - id: "someEntryId#0001", - values: { - "text@nameId": "John Doe" - } - }); - - const convert = createValueKeyFromStorageConverter({ - model, - plugins - }); - - const result = convert({ - fields: model.fields, - values: entry.values - }); - /** - * The createStoredEntry() returns exactly what we are expecting the converter to produce. - * This method was created manually, so there are no automations, and possible errors. - */ - expect(result).toEqual(createStoredEntry().values); - }); }); diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts index b8edd3bd85d..ffb0c3f4d1e 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts @@ -10,6 +10,7 @@ import type { } from "~/types/index.js"; import WebinyError from "@webiny/error"; import camelCase from "lodash/camelCase.js"; +import { EntryValidationError } from "~/domain/contentEntry/errors.js"; type PluginValidationCallable = (params: CmsModelFieldValidatorValidateParams) => Promise; type PluginValidationList = Record; @@ -213,7 +214,7 @@ export const validateModelEntryDataOrThrow = async (params: ValidateModelEntryDa if (invalidFields.length === 0) { return; } - throw new WebinyError("Validation failed.", "VALIDATION_FAILED", invalidFields); + throw new EntryValidationError("Validation failed.", invalidFields); }; /** diff --git a/packages/api-headless-cms/src/domain/contentEntry/errors.ts b/packages/api-headless-cms/src/domain/contentEntry/errors.ts index a6a5bed7131..f23147ec18c 100644 --- a/packages/api-headless-cms/src/domain/contentEntry/errors.ts +++ b/packages/api-headless-cms/src/domain/contentEntry/errors.ts @@ -21,12 +21,13 @@ export class EntryPersistenceError extends BaseError { } } -export class EntryValidationError extends BaseError { +export class EntryValidationError extends BaseError { override readonly code = "Cms/Entry/ValidationError" as const; - constructor(message: string) { + constructor(message: string, data?: any[]) { super({ - message + message, + data: data ?? [] }); } } diff --git a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts index 27d426c9d0e..2a304bcc530 100644 --- a/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts +++ b/packages/api-headless-cms/src/features/contentEntry/CreateEntry/CreateEntryUseCase.ts @@ -91,7 +91,7 @@ class CreateEntryUseCaseImpl implements UseCaseAbstraction.Interface { return Result.ok(entry); } catch (error) { if (error.code === "VALIDATION_FAILED") { - return Result.fail(new EntryValidationError(error.message)); + return Result.fail(new EntryValidationError(error.message, error.data)); } // Handle errors from createEntryData or other operations return Result.fail(error as UseCaseAbstraction.Error); diff --git a/packages/api-scheduler/__tests__/Scheduler.test.ts b/packages/api-scheduler/__tests__/Scheduler.test.ts index 4036d51db57..cfde9e36542 100644 --- a/packages/api-scheduler/__tests__/Scheduler.test.ts +++ b/packages/api-scheduler/__tests__/Scheduler.test.ts @@ -48,7 +48,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: new Date(Date.now() + 1000000) }, + title: "Title", + input: { scheduleOn: new Date(Date.now() + 1000000).toISOString() }, payload: { some: "payload" } }); @@ -87,7 +88,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: new Date(Date.now() + 1000000) }, + title: "Title", + input: { scheduleOn: new Date(Date.now() + 1000000).toISOString() }, payload: { some: "payload" } }); @@ -146,7 +148,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: new Date(Date.now() + 1000000) }, + title: "Title", + input: { scheduleOn: new Date(Date.now() + 1000000).toISOString() }, payload: { some: "payload" } }); @@ -189,7 +192,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: firstDate }, + title: "Title", + input: { scheduleOn: firstDate.toISOString() }, payload: { version: 1 } }); @@ -198,7 +202,7 @@ describe("Scheduler", () => { // Verify first schedule const getFirstResult = await getScheduledAction.execute(scheduleId); expect(getFirstResult.isFail()).toBe(false); - expect(getFirstResult.value.scheduledOn.getTime()).toBe(firstDate.getTime()); + expect(new Date(getFirstResult.value.scheduledOn).getTime()).toBe(firstDate.getTime()); expect(getFirstResult.value.payload).toEqual({ version: 1 }); // Reschedule (same namespace + actionType + targetId) @@ -206,7 +210,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: secondDate }, + title: "Title", + input: { scheduleOn: secondDate.toISOString() }, payload: { version: 2 } }); @@ -216,7 +221,7 @@ describe("Scheduler", () => { const getSecondResult = await getScheduledAction.execute(scheduleId); expect(getSecondResult.isFail()).toBe(false); expect(getSecondResult.value.id).toBe(scheduleId); // Same ID - expect(getSecondResult.value.scheduledOn.getTime()).toBe(secondDate.getTime()); + expect(new Date(getSecondResult.value.scheduledOn).getTime()).toBe(secondDate.getTime()); expect(getSecondResult.value.payload).toEqual({ version: 2 }); }); @@ -232,7 +237,8 @@ describe("Scheduler", () => { namespace, actionType, targetId, - input: { scheduleOn: new Date(Date.now() + 1000000) }, + title: "Title", + input: { scheduleOn: new Date(Date.now() + 1000000).toISOString() }, payload: { some: "payload" } }); @@ -240,7 +246,8 @@ describe("Scheduler", () => { namespace, actionType: "ColonizeMars", targetId, - input: { scheduleOn: new Date(Date.now() + 1000000) }, + title: "Title", + input: { scheduleOn: new Date(Date.now() + 1000000).toISOString() }, payload: { some: "payload" } }); diff --git a/packages/api-scheduler/src/domain/errors.ts b/packages/api-scheduler/src/domain/errors.ts index 01eda0a24ac..d431363e4c9 100644 --- a/packages/api-scheduler/src/domain/errors.ts +++ b/packages/api-scheduler/src/domain/errors.ts @@ -31,10 +31,10 @@ export class ScheduledActionPersistenceError extends BaseError<{ originalError: /** * Invalid schedule date error (e.g., scheduling in the past) */ -export class InvalidScheduleDateError extends BaseError<{ scheduleOn: Date }> { +export class InvalidScheduleDateError extends BaseError<{ scheduleOn: string }> { override readonly code = "Scheduler/ScheduledAction/InvalidDate" as const; - constructor(scheduleOn: Date) { + constructor(scheduleOn: string) { super({ message: "Cannot schedule in the past", data: { scheduleOn } diff --git a/packages/api-scheduler/src/domain/isValidDate.ts b/packages/api-scheduler/src/domain/isValidDate.ts index d1c7cb4a2ef..0ece4095a5d 100644 --- a/packages/api-scheduler/src/domain/isValidDate.ts +++ b/packages/api-scheduler/src/domain/isValidDate.ts @@ -5,8 +5,8 @@ import { SCHEDULE_MIN_FUTURE_SECONDS } from "~/constants.js"; * We need to ensure that the date is at least a SCHEDULE_MIN_FUTURE_MINUTES minutes in the future. * Otherwise, we consider it as "immediate" and run the action right away. */ -export const isValidDate = (date: Date): boolean => { +export const isValidDate = (date: string): boolean => { const minDate = new Date(Date.now() + SCHEDULE_MIN_FUTURE_SECONDS * 1000); - return date.getTime() >= minDate.getTime(); + return new Date(date).getTime() >= minDate.getTime(); }; diff --git a/packages/api-scheduler/src/domain/model.ts b/packages/api-scheduler/src/domain/model.ts index d8627321637..eb9c6ea3365 100644 --- a/packages/api-scheduler/src/domain/model.ts +++ b/packages/api-scheduler/src/domain/model.ts @@ -63,14 +63,14 @@ export const createSchedulerModel = () => { { id: "scheduledOn", fieldId: "scheduledOn", - storageId: "date@scheduledOn", + storageId: "datetime@scheduledOn", type: "datetime", label: "Scheduled On" }, { id: "dateOn", fieldId: "dateOn", - storageId: "date@dateOn", + storageId: "datetime@dateOn", type: "datetime", label: "Date On" }, diff --git a/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts index 2d5a9feafbc..5a7be622733 100644 --- a/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts +++ b/packages/api-scheduler/src/features/GetScheduledAction/GetScheduledActionUseCase.ts @@ -41,7 +41,7 @@ class GetScheduledActionUseCaseImpl implements UseCaseAbstraction.Interface { actionType: entry.values.actionType, targetId: entry.values.targetId, scheduledBy: entry.values.scheduledBy, - scheduledOn: new Date(entry.values.scheduledOn), + scheduledOn: entry.values.scheduledOn, payload: entry.values.payload, title: entry.values.title, error: entry.values.error diff --git a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts index 20dfc47149c..e33be776e3f 100644 --- a/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts +++ b/packages/api-scheduler/src/features/ListScheduledActions/ListScheduledActionsUseCase.ts @@ -52,7 +52,7 @@ class ListScheduledActionsUseCaseImpl implements UseCaseAbstraction.Interface { actionType: entry.values.actionType, targetId: entry.values.targetId, scheduledBy: entry.values.scheduledBy, - scheduledOn: new Date(entry.values.scheduledOn), + scheduledOn: entry.values.scheduledOn, payload: entry.values.payload, error: entry.values.error })); diff --git a/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts index 3b24f76b931..e5a1ea63894 100644 --- a/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts +++ b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts @@ -24,7 +24,7 @@ class RunActionUseCaseImpl implements UseCaseAbstraction.Interface { title: "Unknown title", // Calculate the soonest possible execution time. // Add at least 90 seconds of buffer to ensure EventBridge can process the schedule. - input: { scheduleOn: new Date(Date.now() + 90000) } + input: { scheduleOn: new Date(Date.now() + 90000).toISOString() }, }); if (result.isFail()) { diff --git a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts index 47c089568e3..c03b39a5c68 100644 --- a/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts +++ b/packages/api-scheduler/src/features/ScheduleAction/ScheduleActionUseCase.ts @@ -122,7 +122,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.schedulerService.create({ id: scheduleId, - scheduleOn: input.scheduleOn + scheduleOn: new Date(input.scheduleOn) }); } catch (error) { // Rollback - delete CMS entry if EventBridge fails @@ -157,7 +157,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { const existingId = ScheduledActionIdWithVersion.from(existing.id); const updateResult = await this.updateEntryUseCase.execute(this.model, existingId, { scheduledBy: identity, - scheduledOn: input.scheduleOn.toISOString(), + scheduledOn: input.scheduleOn, payload }); @@ -171,7 +171,7 @@ class ScheduleActionUseCaseImpl implements UseCaseAbstraction.Interface { try { await this.schedulerService.update({ id: existingId, - scheduleOn: input.scheduleOn + scheduleOn: new Date(input.scheduleOn) }); } catch (error) { return Result.fail(new SchedulerServiceError(error as Error)); diff --git a/packages/api-scheduler/src/shared/abstractions.ts b/packages/api-scheduler/src/shared/abstractions.ts index 01a58c1463e..1f82921042b 100644 --- a/packages/api-scheduler/src/shared/abstractions.ts +++ b/packages/api-scheduler/src/shared/abstractions.ts @@ -19,7 +19,7 @@ export interface IScheduledAction { actionType: string; // Operation: "Publish", "Unpublish", "Send", "Delete" targetId: string; // Resource identifier (entry ID, email ID, etc.) scheduledBy: Identity; - scheduledOn: Date; + scheduledOn: string; title?: string; payload?: any; // Action-specific data error?: string; // Error if execution failed @@ -29,7 +29,7 @@ export interface IScheduledAction { * Scheduler Input - When to schedule */ export interface ISchedulerInput { - scheduleOn: Date; // Future date (required) + scheduleOn: string; // Future date (required) } /** From 21bf30bcd49aae973a8049818389f964cc636e05 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj Date: Fri, 5 Dec 2025 10:29:14 +0100 Subject: [PATCH 71/71] fix: polish Sidebar component (#4795) --- .../admin-ui/src/Sidebar/Sidebar.stories.tsx | 68 ++++++- packages/admin-ui/src/Sidebar/Sidebar.tsx | 4 +- .../Sidebar/components/SidebarProvider.tsx | 94 ++++++++- .../components/items/SidebarMenuItem.tsx | 1 + .../items/SidebarMenuItemAction.tsx | 20 +- .../components/items/SidebarMenuItemIcon.tsx | 2 +- .../items/SidebarMenuPinnedItems.tsx | 53 +++++ .../components/items/SidebarMenuProvider.tsx | 11 +- .../components/items/SidebarMenuRoot.tsx | 9 +- .../items/SidebarMenuRootButton.tsx | 19 +- .../components/items/SidebarMenuRootItem.tsx | 111 ++++++++++- .../components/items/SidebarMenuSub.tsx | 8 +- .../components/items/SidebarMenuSubButton.tsx | 27 ++- .../components/items/SidebarMenuSubItem.tsx | 123 +++++++++++- .../UpdateFlpOnFolderUpdatedHandler.ts | 4 +- .../GetAncestors/GetAncestorsRepository.ts | 4 +- .../GetAncestors/GetAncestorsUseCase.ts | 4 +- .../folders/GetAncestors/abstractions.ts | 5 +- .../GetFolderHierarchy/abstractions.ts | 5 +- .../folders/ListFolders/ListFoldersUseCase.ts | 5 +- .../crud/contentEntry/entryDataValidation.ts | 1 - .../features/RunAction/RunActionUseCase.ts | 2 +- packages/app-admin-ui/src/Navigation.tsx | 2 - .../src/Navigation/DecoratedMenu.tsx | 36 ---- .../src/Navigation/Navigation.tsx | 2 - .../src/Navigation/PinnableMenuItem.tsx | 122 ------------ .../src/Navigation/PinnedMenuItems.tsx | 188 ------------------ .../src/Navigation/SidebarMenuItems.tsx | 17 +- .../src/Navigation/SidebarProvider.tsx | 29 ++- .../app-admin-users-cognito/src/Cognito.tsx | 9 +- packages/app-admin/src/base/Base/Menus.tsx | 1 + packages/app-audit-logs/src/index.tsx | 1 + .../src/modules/Settings/index.tsx | 5 +- .../CmsWorkflows/CmsWorkflowsEditorView.tsx | 2 +- .../src/admin/menus/CmsMenuLoader.tsx | 16 +- .../src/admin/menus/GroupContentModels.tsx | 2 +- packages/app-mailer/src/Module.tsx | 9 +- .../src/Extension.tsx | 23 ++- .../app-website-builder/src/Extension.tsx | 30 +-- .../Components/AdminConfig/WorkflowsMenu.tsx | 2 +- .../icons/src/extraIcons/push_pin_off.svg | 2 +- 41 files changed, 599 insertions(+), 479 deletions(-) create mode 100644 packages/admin-ui/src/Sidebar/components/items/SidebarMenuPinnedItems.tsx delete mode 100644 packages/app-admin-ui/src/Navigation/DecoratedMenu.tsx delete mode 100644 packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx delete mode 100644 packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx diff --git a/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx b/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx index 38cd3a9fb7f..1eb2e451d26 100644 --- a/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx +++ b/packages/admin-ui/src/Sidebar/Sidebar.stories.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck Temporary fix. import React from "react"; import type { Meta, StoryObj } from "@storybook/react-webpack5"; import { BrowserRouter, Route, Routes, useLocation } from "react-router"; @@ -13,6 +12,7 @@ import { ReactComponent as DocsIcon } from "@webiny/icons/summarize.svg"; import { ReactComponent as ApiPlaygroundIcon } from "@webiny/icons/swap_horiz.svg"; import { ReactComponent as MoreVertIcon } from "@webiny/icons/more_vert.svg"; import { ReactComponent as FileManagerIcon } from "@webiny/icons/insert_drive_file.svg"; +import { ReactComponent as GridIcon } from "@webiny/icons/grid_4x4.svg"; import { Sidebar } from "./Sidebar.js"; import { SidebarProvider } from "~/Sidebar/components/SidebarProvider.js"; import { DropdownMenu } from "~/DropdownMenu/index.js"; @@ -59,8 +59,10 @@ export const MainMenu: Story = { const SidebarComponent = () => { const { hash } = useLocation(); + const [pinnedItems, setPinnedItems] = React.useState([]); + return ( - + { } className={"w-[225px]"} > - } - /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> - Webiny 5.43.0 + Webiny 6.0.0 { } > } + onClick={() => console.log("More action clicked")} + /> + } to={"#audit-logs"} active={hash === "#audit-logs"} icon={} />} /> } />} /> { alert("File Manager clicked"); }} icon={} />} /> + } />} + > + } />} + action={} />} + > + + + {" "} + } />} + action={} />} + > + + + + } />} > { - {props.children} + {props.children} - {footerProps.footer} + {footerProps.footer} ); diff --git a/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx b/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx index 9160b25b80a..cb9d884a37c 100644 --- a/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx +++ b/packages/admin-ui/src/Sidebar/components/SidebarProvider.tsx @@ -3,6 +3,15 @@ import { cn } from "~/utils.js"; import { SIDEBAR_TRANSITION_DURATION } from "./constants.js"; import { SidebarCache } from "./SidebarCache.js"; +type PinnedItemData = { + id: string; + text: React.ReactNode; + icon?: React.ReactNode; + to?: string; + onClick?: React.MouseEventHandler; + active?: boolean; +}; + type SidebarContext = { state: "expanded" | "collapsed"; expanded: boolean; @@ -14,6 +23,12 @@ type SidebarContext = { togglePinned: () => void; toggleSectionExpanded: (sectionId: string) => void; isSectionExpanded: (sectionId: string) => boolean; + pinnedItems: string[]; + toggleItemPinned: (itemId: string) => void; + isItemPinned: (itemId: string) => boolean; + registerPinnedItem: (data: PinnedItemData) => void; + unregisterPinnedItem: (itemId: string) => void; + getPinnedItemsData: () => PinnedItemData[]; }; const SidebarContext = React.createContext(null); @@ -27,7 +42,10 @@ function useSidebar() { return context; } -type SidebarProviderProps = React.HTMLAttributes; +type SidebarProviderProps = React.HTMLAttributes & { + pinnedItems?: string[]; + onChangePinnedItems?: (pinnedItems: string[]) => void; +}; interface SidebarState { expanded: boolean; @@ -46,8 +64,17 @@ const createInitialSidebarState = (): SidebarState => { }; }; -const SidebarProvider = ({ className, children, ...props }: SidebarProviderProps) => { +const SidebarProvider = ({ + className, + children, + pinnedItems = [], + onChangePinnedItems, + ...props +}: SidebarProviderProps) => { const [sidebarState, setSidebarState] = React.useState(createInitialSidebarState); + const [pinnedItemsData, setPinnedItemsData] = React.useState>( + new Map() + ); // With this timeout, we prevent the sidebar glitching (quickly opening/closing) during mouse enter/leave events. const timeoutRef = React.useRef(null); @@ -124,6 +151,51 @@ const SidebarProvider = ({ className, children, ...props }: SidebarProviderProps [expandedSections] ); + const toggleItemPinned = React.useCallback( + (itemId: string) => { + if (!onChangePinnedItems) { + return; + } + + const newPinnedItems = pinnedItems.includes(itemId) + ? pinnedItems.filter(id => id !== itemId) + : [...pinnedItems, itemId]; + + onChangePinnedItems(newPinnedItems); + }, + [pinnedItems, onChangePinnedItems] + ); + + const isItemPinned = React.useCallback( + (itemId: string) => { + return pinnedItems.includes(itemId); + }, + [pinnedItems] + ); + + const registerPinnedItem = React.useCallback((data: PinnedItemData) => { + setPinnedItemsData(prev => { + const newMap = new Map(prev); + newMap.set(data.id, data); + return newMap; + }); + }, []); + + const unregisterPinnedItem = React.useCallback((itemId: string) => { + setPinnedItemsData(prev => { + const newMap = new Map(prev); + newMap.delete(itemId); + return newMap; + }); + }, []); + + const getPinnedItemsData = React.useCallback(() => { + // Sort by the order in pinnedItems array to maintain consistent ordering + return pinnedItems + .map(id => pinnedItemsData.get(id)) + .filter((item): item is PinnedItemData => item !== undefined); + }, [pinnedItemsData, pinnedItems]); + // We add a state so that we can do data-state="expanded" or "collapsed". // This makes it easier to style the sidebar with Tailwind classes. const state = expanded ? "expanded" : "collapsed"; @@ -140,7 +212,13 @@ const SidebarProvider = ({ className, children, ...props }: SidebarProviderProps toggleSectionExpanded, setPinned, togglePinned, - isSectionExpanded + isSectionExpanded, + pinnedItems, + toggleItemPinned, + isItemPinned, + registerPinnedItem, + unregisterPinnedItem, + getPinnedItemsData }), [ state, @@ -151,7 +229,13 @@ const SidebarProvider = ({ className, children, ...props }: SidebarProviderProps setExpanded, setPinned, toggleExpanded, - togglePinned + togglePinned, + pinnedItems, + toggleItemPinned, + isItemPinned, + registerPinnedItem, + unregisterPinnedItem, + getPinnedItemsData ] ); @@ -169,4 +253,4 @@ const SidebarProvider = ({ className, children, ...props }: SidebarProviderProps }; SidebarProvider.displayName = "SidebarProvider"; -export { SidebarProvider, useSidebar }; +export { SidebarProvider, useSidebar, type PinnedItemData }; diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItem.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItem.tsx index 97f6b8368e1..25b7afaa089 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItem.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItem.tsx @@ -17,6 +17,7 @@ export interface SidebarMenuItemBaseProps { variant?: "group-label"; active?: boolean; disabled?: boolean; + pinnable?: boolean; } type SidebarMenuItemButtonProps = SidebarMenuItemBaseProps & { diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemAction.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemAction.tsx index 61f773817c8..34b02d76cc6 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemAction.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemAction.tsx @@ -1,17 +1,33 @@ import React from "react"; +import { cn } from "~/utils.js"; import { IconButton, type IconButtonProps } from "~/Button/IconButton.js"; interface SidebarMenuItemActionProps extends Omit { element?: React.ReactNode; + showOnHover?: boolean; } -const SidebarMenuItemAction = ({ element, ...props }: SidebarMenuItemActionProps) => { +const SidebarMenuItemAction = ({ + element, + showOnHover = false, + className, + ...props +}: SidebarMenuItemActionProps) => { return ( ); diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemIcon.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemIcon.tsx index c3b120a7203..3c28fcb9873 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemIcon.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuItemIcon.tsx @@ -7,7 +7,7 @@ interface SidebarMenuItemIconProps extends Omit { } const SidebarMenuItemIconBase = ({ element, ...props }: SidebarMenuItemIconProps) => { - return ; + return ; }; const SidebarMenuItemIcon = makeDecoratable("SidebarMenuItemIcon", SidebarMenuItemIconBase); diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuPinnedItems.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuPinnedItems.tsx new file mode 100644 index 00000000000..525d75814c1 --- /dev/null +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuPinnedItems.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useSidebar } from "~/Sidebar/index.js"; +import { SidebarMenuRootItem } from "./SidebarMenuRootItem.js"; +import { SidebarMenuItemAction } from "./SidebarMenuItemAction.js"; +import { ReactComponent as UnPinIcon } from "@webiny/icons/push_pin_off.svg"; + +const SidebarMenuPinnedItems = () => { + const sidebar = useSidebar(); + + // Don't memoize - we want this to re-render when active state changes + const pinnedItems = sidebar.getPinnedItemsData(); + + if (pinnedItems.length === 0) { + return null; + } + + return ( + <> + {pinnedItems.map(item => { + const handleUnpin = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + sidebar.toggleItemPinned(item.id); + }; + + const unpinAction = ( + } + onClick={handleUnpin} + showOnHover={true} + /> + ); + + return ( + + ); + })} +
  • +
    +
  • + + ); +}; + +export { SidebarMenuPinnedItems }; diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuProvider.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuProvider.tsx index 42e30b333ad..87b393b613f 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuProvider.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuProvider.tsx @@ -3,16 +3,19 @@ import React from "react"; interface SidebarMenuContext { currentLevel: number; nextLevel: number; + parentIcon?: React.ReactNode; } interface SidebarMenuProviderProps { level?: number; + parentIcon?: React.ReactNode; children: React.ReactNode; } const SidebarMenuContext = React.createContext({ currentLevel: 0, - nextLevel: 1 + nextLevel: 1, + parentIcon: undefined }); function useSidebarMenu() { @@ -24,9 +27,11 @@ function useSidebarMenu() { return context; } -const SidebarMenuProvider = ({ level = 0, children }: SidebarMenuProviderProps) => { +const SidebarMenuProvider = ({ level = 0, parentIcon, children }: SidebarMenuProviderProps) => { return ( - + {children} ); diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRoot.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRoot.tsx index 91211fc5c91..efb2cc7ad75 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRoot.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRoot.tsx @@ -1,13 +1,18 @@ import React from "react"; import { SidebarMenuProvider } from "./SidebarMenuProvider.js"; +import { SidebarMenuPinnedItems } from "./SidebarMenuPinnedItems.js"; interface SidebarMenuProps { children: React.ReactNode; + showPinnedItems?: boolean; } -const SidebarMenuRoot = (props: SidebarMenuProps) => ( +const SidebarMenuRoot = ({ children, showPinnedItems = true, ...props }: SidebarMenuProps) => ( -
      +
        + {showPinnedItems && } + {children} +
      ); diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootButton.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootButton.tsx index 6181b1eef4a..623c78dbd8e 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootButton.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootButton.tsx @@ -11,6 +11,7 @@ const variants = cva( "text-md outline-none transition-[width,height,padding]", "whitespace-nowrap overflow-hidden", "hover:bg-neutral-dark/5", + "group-hover/menu-root-button:bg-neutral-dark/5", "focus:bg-neutral-dark/5 focus:ring-none focus:ring-transparent", "data-[active=true]:bg-neutral-dark/5 data-[active=true]:font-semibold data-[active=true]:pointer-events-none", "group-data-[state=open]/menu-item-collapsible:font-semibold!" @@ -18,7 +19,8 @@ const variants = cva( { variants: { variant: { - "group-label": "text-neutral-muted! uppercase" + "group-label": + "text-neutral-muted! uppercase hover:bg-transparent! group-hover/menu-root-button:bg-transparent! focus:bg-transparent! cursor-default!" }, disabled: { true: "pointer-events-none text-neutral-disabled!" @@ -48,15 +50,12 @@ const SidebarMenuRootButton = ({ onClick }; - const chevron = action ?
      {action}
      : null; - const { linkComponent: LinkComponent } = useAdminUi(); const content = to ? ( {icon} {text} - {chevron} ) : ( {icon} {text} - {chevron} ); - return
      {content}
      ; + return ( +
      + {content} + {action && ( +
      + {action} +
      + )} +
      + ); }; export { SidebarMenuRootButton }; diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootItem.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootItem.tsx index c54f53cad6b..38d23276939 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootItem.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuRootItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useEffect } from "react"; import { cn, makeDecoratable, withStaticProps } from "~/utils.js"; import { SidebarMenuRootButton } from "./SidebarMenuRootButton.js"; import { SidebarMenuItemIcon } from "./SidebarMenuItemIcon.js"; @@ -7,18 +7,28 @@ import { SidebarMenuSub } from "./SidebarMenuSub.js"; import { Collapsible } from "radix-ui"; import { Icon } from "~/Icon/index.js"; import { ReactComponent as KeyboardArrowRightIcon } from "@webiny/icons/keyboard_arrow_down.svg"; +import { ReactComponent as PinIcon } from "@webiny/icons/push_pin.svg"; +import { ReactComponent as UnPinIcon } from "@webiny/icons/push_pin_off.svg"; import { type SidebarMenuItemProps } from "./SidebarMenuItem.js"; import { useSidebarMenu } from "~/Sidebar/components/items/SidebarMenuProvider.js"; import { useSidebar } from "~/Sidebar/index.js"; -const SidebarMenuItemBase = ({ children, className, ...buttonProps }: SidebarMenuItemProps) => { - const { currentLevel } = useSidebarMenu(); +const SidebarMenuItemBase = ({ + children, + className, + pinnable, + action, + ...buttonProps +}: SidebarMenuItemProps) => { + const { currentLevel, parentIcon } = useSidebarMenu(); const sidebar = useSidebar(); const menuItemId = useMemo(() => { return btoa(`sidebar-item-${currentLevel}-${buttonProps.text}`); }, [buttonProps.text, currentLevel]); + const effectiveIcon = buttonProps.icon || parentIcon; + const isSectionExpanded = useMemo(() => { return sidebar.isSectionExpanded(menuItemId); }, [sidebar.expandedSections]); @@ -27,9 +37,79 @@ const SidebarMenuItemBase = ({ children, className, ...buttonProps }: SidebarMen sidebar.toggleSectionExpanded(menuItemId); }, [isSectionExpanded]); + const isPinned = sidebar.isItemPinned(menuItemId); + + // Register on mount if already pinned, unregister on unmount + // Re-register when active state changes to keep pinned items in sync + useEffect(() => { + if (pinnable && isPinned) { + sidebar.registerPinnedItem({ + id: menuItemId, + text: buttonProps.text, + icon: effectiveIcon, + to: buttonProps.to, + onClick: buttonProps.onClick, + active: buttonProps.active + }); + } + + return () => { + if (pinnable) { + sidebar.unregisterPinnedItem(menuItemId); + } + }; + }, [pinnable, isPinned, menuItemId, buttonProps.active]); + + const pinAction = useMemo(() => { + if (!pinnable) { + return action; + } + + const handlePinClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (isPinned) { + sidebar.unregisterPinnedItem(menuItemId); + } else { + sidebar.registerPinnedItem({ + id: menuItemId, + text: buttonProps.text, + icon: effectiveIcon, + to: buttonProps.to, + onClick: buttonProps.onClick, + active: buttonProps.active + }); + } + + sidebar.toggleItemPinned(menuItemId); + }; + + const pinButton = ( + : } + onClick={handlePinClick} + showOnHover={true} + /> + ); + + // If there's a custom action, combine them + // Don't modify the custom action - it should keep its original behavior + if (action) { + return ( +
      + {pinButton} + {action} +
      + ); + } + + return pinButton; + }, [pinnable, isPinned, action, sidebar, menuItemId]); + const sidebarMenuButton = useMemo(() => { if (!children) { - return ; + return ; } const chevron = ( @@ -45,6 +125,15 @@ const SidebarMenuItemBase = ({ children, className, ...buttonProps }: SidebarMen /> ); + const collapsibleAction = pinnable ? ( +
      + {pinAction} + {chevron} +
      + ) : ( + chevron + ); + return ( - + - {children} + {children} ); - }, [children, buttonProps, menuItemId, isSectionExpanded, toggleSectionExpanded]); + }, [ + children, + buttonProps, + menuItemId, + isSectionExpanded, + toggleSectionExpanded, + pinnable, + pinAction + ]); return (
    • ) => { +interface SidebarMenuSubProps extends React.HTMLAttributes { + parentIcon?: React.ReactNode; +} + +const SidebarMenuSub = ({ className, parentIcon, ...props }: SidebarMenuSubProps) => { const parentSidebarMenu = useSidebarMenu(); return ( - +
        ; +type SidebarMenuSubButtonProps = DistributedOmit; const SidebarMenuSubButton = ({ onClick, @@ -42,15 +43,16 @@ const SidebarMenuSubButton = ({ icon, action, text, + className, to, ...linkProps }: SidebarMenuSubButtonProps) => { const { linkComponent: LinkComponent } = useAdminUi(); const sharedProps = { - "data-sidebar": "menu-button", + "data-sidebar": "menu-sub-button", "data-active": active, - className: variants({ variant, disabled }), + className: variants({ variant, disabled, className }), onClick }; @@ -60,6 +62,8 @@ const SidebarMenuSubButton = ({ {text} ) : ( + // We can't use the default button element here because the content of the button + // can also contain a button, which is not allowed in HTML. ); - // We can't use the default button element here because the content of the button - // can also contain a button, which is not allowed in HTML. return ( -
        +
        {content} -
        {action}
        + + {action && ( +
        + {action} +
        + )}
        ); }; diff --git a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx index d1b7c048b99..8ea84199fdb 100644 --- a/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx +++ b/packages/admin-ui/src/Sidebar/components/items/SidebarMenuSubItem.tsx @@ -6,18 +6,29 @@ import { SidebarMenuSubItemIndentation } from "./SidebarMenuSubItemIndentation.j import { SidebarMenuSub } from "./SidebarMenuSub.js"; import { Icon } from "~/Icon/index.js"; import { ReactComponent as KeyboardArrowRightIcon } from "@webiny/icons/keyboard_arrow_down.svg"; +import { ReactComponent as PinIcon } from "@webiny/icons/push_pin.svg"; +import { ReactComponent as UnPinIcon } from "@webiny/icons/push_pin_off.svg"; import { useSidebarMenu } from "./SidebarMenuProvider.js"; import { type SidebarMenuItemProps } from "./SidebarMenuItem.js"; import { useSidebar } from "~/Sidebar/index.js"; +import { SidebarMenuItemAction } from "./SidebarMenuItemAction.js"; -const SidebarMenuSubItem = ({ children, className, ...buttonProps }: SidebarMenuItemProps) => { - const { currentLevel } = useSidebarMenu(); +const SidebarMenuSubItem = ({ + children, + className, + pinnable, + action, + ...buttonProps +}: SidebarMenuItemProps) => { + const { currentLevel, parentIcon } = useSidebarMenu(); const sidebar = useSidebar(); const menuItemId = useMemo(() => { return btoa(`sidebar-item-${currentLevel}-${buttonProps.text}`); }, [buttonProps.text, currentLevel]); + const effectiveIcon = buttonProps.icon || parentIcon; + const isSectionExpanded = useMemo(() => { return sidebar.isSectionExpanded(menuItemId); }, [sidebar.expandedSections]); @@ -26,6 +37,76 @@ const SidebarMenuSubItem = ({ children, className, ...buttonProps }: SidebarMenu sidebar.toggleSectionExpanded(menuItemId); }, [isSectionExpanded]); + const isPinned = sidebar.isItemPinned(menuItemId); + + // Register on mount if already pinned, unregister on unmount + // Re-register when active state changes to keep pinned items in sync + React.useEffect(() => { + if (pinnable && isPinned) { + sidebar.registerPinnedItem({ + id: menuItemId, + text: buttonProps.text, + icon: effectiveIcon, + to: buttonProps.to, + onClick: buttonProps.onClick, + active: buttonProps.active + }); + } + + return () => { + if (pinnable) { + sidebar.unregisterPinnedItem(menuItemId); + } + }; + }, [pinnable, isPinned, menuItemId, buttonProps.active]); + + const pinAction = useMemo(() => { + if (!pinnable) { + return action; + } + + const handlePinClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + + if (isPinned) { + sidebar.unregisterPinnedItem(menuItemId); + } else { + sidebar.registerPinnedItem({ + id: menuItemId, + text: buttonProps.text, + icon: effectiveIcon, + to: buttonProps.to, + onClick: buttonProps.onClick, + active: buttonProps.active + }); + } + + sidebar.toggleItemPinned(menuItemId); + }; + + const pinButton = ( + : } + onClick={handlePinClick} + showOnHover={true} + /> + ); + + // If there's a custom action, combine them + // Don't modify the custom action - it should keep its original behavior + if (action) { + return ( +
        + {pinButton} + {action} +
        + ); + } + + return pinButton; + }, [pinnable, isPinned, action, sidebar, menuItemId]); + const sidebarMenuSubButton = useMemo(() => { if (!children) { return ( @@ -34,7 +115,7 @@ const SidebarMenuSubItem = ({ children, className, ...buttonProps }: SidebarMenu lvl={currentLevel} variant={buttonProps.variant} /> - + ); } @@ -52,23 +133,51 @@ const SidebarMenuSubItem = ({ children, className, ...buttonProps }: SidebarMenu /> ); + const collapsibleAction = pinnable ? ( +
        + {pinAction} + {chevron} +
        + ) : ( + chevron + ); + return ( - +
        - +
        - {children} + {children}
        ); - }, [children, buttonProps, currentLevel, menuItemId, isSectionExpanded, toggleSectionExpanded]); + }, [ + children, + buttonProps, + currentLevel, + menuItemId, + isSectionExpanded, + toggleSectionExpanded, + pinnable, + pinAction + ]); return (
      • > { + async execute( + params: GetAncestorsParams + ): Promise> { const { folder } = params; // No folder found: return an empty array diff --git a/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts index d8a206d6989..8fddb41515b 100644 --- a/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts +++ b/packages/api-aco/src/features/folders/GetAncestors/GetAncestorsUseCase.ts @@ -10,7 +10,9 @@ import { createImplementation } from "@webiny/di"; class GetAncestorsUseCaseImpl implements UseCaseAbstraction.Interface { constructor(private repository: GetAncestorsRepository.Interface) {} - public async execute(params: GetAncestorsParams): Promise> { + public async execute( + params: GetAncestorsParams + ): Promise> { return await this.repository.execute(params); } } diff --git a/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts b/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts index 6eae2a71582..9e03e635586 100644 --- a/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts +++ b/packages/api-aco/src/features/folders/GetAncestors/abstractions.ts @@ -1,10 +1,7 @@ import { createAbstraction } from "@webiny/feature/api"; import type { Result } from "@webiny/feature/api"; import type { Folder } from "~/folder/folder.types.js"; -import type { - FolderNotAuthorizedError, - FolderPersistenceError -} from "~/domain/folder/errors.js"; +import type { FolderNotAuthorizedError, FolderPersistenceError } from "~/domain/folder/errors.js"; /** * GetAncestors repository interface diff --git a/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts b/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts index e065d840604..32cc3210618 100644 --- a/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts +++ b/packages/api-aco/src/features/folders/GetFolderHierarchy/abstractions.ts @@ -4,10 +4,7 @@ import type { GetFolderHierarchyParams, GetFolderHierarchyResponse } from "~/folder/folder.types.js"; -import type { - FolderNotAuthorizedError, - FolderPersistenceError -} from "~/domain/folder/errors.js"; +import type { FolderNotAuthorizedError, FolderPersistenceError } from "~/domain/folder/errors.js"; /** * GetFolderHierarchy repository interface diff --git a/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts b/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts index dd2613ba6ff..c1d519a5815 100644 --- a/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts +++ b/packages/api-aco/src/features/folders/ListFolders/ListFoldersUseCase.ts @@ -1,9 +1,6 @@ import { Result } from "@webiny/feature/api"; import { createImplementation } from "@webiny/feature/api"; -import { - ListFoldersUseCase as UseCaseAbstraction, - ListFoldersRepository -} from "./abstractions.js"; +import { ListFoldersUseCase as UseCaseAbstraction, ListFoldersRepository } from "./abstractions.js"; import type { Folder, ListFoldersParams } from "~/folder/folder.types.js"; import type { ListMeta } from "~/types.js"; diff --git a/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts b/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts index ffb0c3f4d1e..b63f7d624b2 100644 --- a/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts +++ b/packages/api-headless-cms/src/crud/contentEntry/entryDataValidation.ts @@ -8,7 +8,6 @@ import type { CmsModelFieldValidatorPlugin, CmsModelFieldValidatorValidateParams } from "~/types/index.js"; -import WebinyError from "@webiny/error"; import camelCase from "lodash/camelCase.js"; import { EntryValidationError } from "~/domain/contentEntry/errors.js"; diff --git a/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts index e5a1ea63894..027462d4598 100644 --- a/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts +++ b/packages/api-scheduler/src/features/RunAction/RunActionUseCase.ts @@ -24,7 +24,7 @@ class RunActionUseCaseImpl implements UseCaseAbstraction.Interface { title: "Unknown title", // Calculate the soonest possible execution time. // Add at least 90 seconds of buffer to ensure EventBridge can process the schedule. - input: { scheduleOn: new Date(Date.now() + 90000).toISOString() }, + input: { scheduleOn: new Date(Date.now() + 90000).toISOString() } }); if (result.isFail()) { diff --git a/packages/app-admin-ui/src/Navigation.tsx b/packages/app-admin-ui/src/Navigation.tsx index 1816ff0622a..35a542d6028 100644 --- a/packages/app-admin-ui/src/Navigation.tsx +++ b/packages/app-admin-ui/src/Navigation.tsx @@ -2,14 +2,12 @@ import React from "react"; import { Provider } from "@webiny/app-admin"; import { SidebarProvider } from "./Navigation/SidebarProvider.js"; import { Navigation as DecoratedNavigation } from "./Navigation/Navigation.js"; -import { DecoratedMenu } from "./Navigation/DecoratedMenu.js"; export const Navigation = () => { return ( <> - ); }; diff --git a/packages/app-admin-ui/src/Navigation/DecoratedMenu.tsx b/packages/app-admin-ui/src/Navigation/DecoratedMenu.tsx deleted file mode 100644 index e3a0b219343..00000000000 --- a/packages/app-admin-ui/src/Navigation/DecoratedMenu.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; -import { AdminConfig } from "@webiny/app-admin"; -import { type SidebarMenuItemButtonProps } from "@webiny/admin-ui/Sidebar/components/items/SidebarMenuItem.js"; -import { type SidebarMenuItemLinkProps } from "@webiny/admin-ui/Sidebar/components/items/SidebarMenuLink.js"; -import { useMenuParentIcon } from "./PinnedMenuItems.js"; - -const { Menu } = AdminConfig; - -const MenuItemWithParentIcon = Menu.Item.createDecorator(Original => { - return function MenuItemRenderer(props: SidebarMenuItemButtonProps) { - const icon = useMenuParentIcon(); - if (icon) { - return ; - } - return ; - }; -}); - -const MenuLinkWithParentIcon = Menu.Link.createDecorator(Original => { - return function MenuLinkRenderer(props: SidebarMenuItemLinkProps) { - const icon = useMenuParentIcon(); - if (icon) { - return ; - } - return ; - }; -}); - -export const DecoratedMenu = () => { - return ( - <> - - - - ); -}; diff --git a/packages/app-admin-ui/src/Navigation/Navigation.tsx b/packages/app-admin-ui/src/Navigation/Navigation.tsx index cb93152535e..e395585bf05 100644 --- a/packages/app-admin-ui/src/Navigation/Navigation.tsx +++ b/packages/app-admin-ui/src/Navigation/Navigation.tsx @@ -3,7 +3,6 @@ import { NavigationRenderer, useAdminConfig } from "@webiny/app-admin"; import { Sidebar } from "@webiny/admin-ui"; import { SidebarMenuItems } from "./SidebarMenuItems.js"; import { SimpleLink } from "@webiny/app-admin"; -import { PinnedMenuItems } from "./PinnedMenuItems.js"; export const Navigation = NavigationRenderer.createDecorator(() => { return function Navigation() { @@ -22,7 +21,6 @@ export const Navigation = NavigationRenderer.createDecorator(() => { icon={icon} footer={} > - ); diff --git a/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx b/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx deleted file mode 100644 index 39591e39f48..00000000000 --- a/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React from "react"; -import { Icon } from "@webiny/admin-ui"; -import { ReactComponent as PinIcon } from "@webiny/icons/push_pin.svg"; -import { ReactComponent as UnPinIcon } from "@webiny/icons/push_pin_off.svg"; -import { useLocalStorage, useLocalStorageValue } from "@webiny/app"; - -/** - * Props for the PinnableMenuItem component. - * - * @property name - Unique string identifier for the menu item. Used for localStorage keys. - * @property children - React node(s) representing the menu item's content. - */ -type PinnableMenuItemProps = { - name: string; - children: React.ReactNode; -}; - -/** - * Generates the localStorage key for a pinned menu item. - * - * @param name - The unique name of the menu item. - * @returns The localStorage key string for the pinned state. - */ -export const createPinnedKey = (name: string) => `navigation/${name}/pinned`; - -/** - * The localStorage key for the order of pinned menu items. - */ -export const PINNED_ORDER_KEY = "navigation/order/pinned"; -/** - * Parses the pinned order value from localStorage. - * - * @param order - The value retrieved from localStorage (string or array). - * @returns An array of menu item names in pinned order. - */ -const parseOrder = (order: unknown): string[] => { - if (Array.isArray(order)) { - return order; - } - if (typeof order === "string") { - try { - const parsed = JSON.parse(order); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } - } - return []; -}; - -/** - * Custom hook to manage the pinned state of a menu item. - * - * @param name - Unique string identifier for the menu item. - * @returns An object containing: - * - isPinned: boolean | undefined - Whether the menu item is pinned. - * - pin: () => void - Function to pin the menu item. - * - unpin: () => void - Function to unpin the menu item. - * - * @sideEffect Updates localStorage when pinning/unpinning. - * @note The pinned state and order are persisted in localStorage. - */ -const usePinnedMenuItem = (name: string) => { - const pinKey = createPinnedKey(name); - const pinOrder = useLocalStorageValue(PINNED_ORDER_KEY); - const isPinned = useLocalStorageValue(pinKey); - const { set, remove } = useLocalStorage(); - - const updateOrder = (order: string[]) => set(PINNED_ORDER_KEY, JSON.stringify(order)); - - const pin = () => { - const order = parseOrder(pinOrder); - if (!order.includes(name)) { - updateOrder([...order, name]); - } - set(pinKey, true); - }; - - const unpin = () => { - const order = parseOrder(pinOrder).filter(item => item !== name); - updateOrder(order); - remove(pinKey); - }; - - return { isPinned, pin, unpin }; -}; - -/** - * PinnableMenuItem component allows any menu item to be "pinned" by the user. - * The pinned state is persisted in localStorage, making the menu item visually distinct and easily accessible. - * - * @param props - {@link PinnableMenuItemProps} - * @returns JSX.Element - Renders the children and a pin/unpin icon. - * - * @example - * - * - * - * - * @sideEffect Persists pinned state and order in localStorage. - * @note The pin icon appears on hover and toggles the pinned state. - */ -export const PinnableMenuItem = ({ name, children }: PinnableMenuItemProps) => { - const { isPinned, pin, unpin } = usePinnedMenuItem(name); - - return ( -
        - {children} -
        - : } - className="fill-neutral-strong hover:fill-neutral-xstrong" - /> -
        -
        - ); -}; diff --git a/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx b/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx deleted file mode 100644 index be3ea02f45e..00000000000 --- a/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { useMemo } from "react"; -import { AdminConfig, useLocalStorageValues, useLocalStorageValue } from "@webiny/app-admin"; -import { createPinnedKey, PINNED_ORDER_KEY, PinnableMenuItem } from "./PinnableMenuItem.js"; -import type { MenuConfig } from "@webiny/app-admin/config/AdminConfig/Menu.js"; -import { Separator } from "@webiny/admin-ui"; -import { createGenericContext } from "@webiny/app-admin"; - -/** - * Context for providing a parent menu's icon in navigation components. - * - * @typeParam icon - React.ReactNode representing the icon for the parent menu. - */ -const MenuParentContext = createGenericContext<{ icon: React.ReactNode }>("MenuParent"); - -/** - * Hook to access the parent menu icon from the MenuParentContext. - * - * @returns {React.ReactNode | undefined} The icon provided by the parent menu context, or `undefined` if the context is not available. - * - * @example - * // Usage inside a component wrapped by MenuParentContext.Provider - * const icon = useMenuParentIcon(); - * if (icon) { - * return {icon}; - * } - * - * @remarks - * If called outside of a MenuParentContext.Provider, this hook will return `undefined`. - */ -export const useMenuParentIcon = () => { - try { - const { icon } = MenuParentContext.useHook(); - return icon; - } catch { - return undefined; - } -}; - -/** - * Retrieves the icon from the parent menu of a given child menu. - * - * @param childMenu - The menu configuration object representing the child menu. - * @param allMenus - An array of all menu configuration objects. - * @returns The React node representing the parent's icon, or `undefined` if no parent or icon is found. - * - * @remarks - * - If the child menu does not have a parent, or the parent menu's element is not a valid React element, returns `undefined`. - * - Assumes that the parent menu's element has an `icon` prop. - * - * @example - * const icon = getParentIcon(childMenu, allMenus); - * if (icon) { - * // Render the icon - * } - */ -const getParentIcon = ( - childMenu: MenuConfig, - allMenus: MenuConfig[] -): React.ReactNode | undefined => { - if (!childMenu.parent) { - return undefined; - } - const parentMenu = allMenus.find(menu => menu.name === childMenu.parent); - if (!parentMenu || !React.isValidElement(parentMenu.element)) { - return undefined; - } - // Type assertion to fix 'unknown' type error - return (parentMenu.element.props as { icon?: React.ReactNode }).icon; -}; - -/** - * Props for the PinnedMenuItems component. - * @property menuItems - Array of menu item objects from admin config. - */ -export interface PinnedMenuItemsProps { - menuItems: ReturnType["menus"]; -} - -/** - * Filters menu items to include only those that are pinnable. - * @param menuItems - Array of menu item objects. - * @returns Array of pinnable menu items. - */ -const getPinnableMenus = (menuItems: PinnedMenuItemsProps["menuItems"]) => - menuItems?.filter(({ pinnable }) => pinnable) || []; - -/** - * Generates local storage keys for each pinnable menu item. - * @param menus - Array of menu item objects. - * @returns Array of local storage key strings. - */ -const getPinnableKeys = (menus: PinnedMenuItemsProps["menuItems"]) => - menus.map(({ name }) => createPinnedKey(name)); - -/** - * Parses the pinned order from a raw local storage value. - * @param rawOrder - Value from local storage (string or array). - * @returns Array of menu item names in pinned order. - * - * Note: If parsing fails, returns an empty array. - */ -const parsePinnedOrder = (rawOrder: unknown): string[] => { - if (Array.isArray(rawOrder)) { - return rawOrder; - } - if (typeof rawOrder === "string") { - try { - const parsed = JSON.parse(rawOrder); - if (Array.isArray(parsed)) { - return parsed; - } - } catch { - // ignore parse error, fallback to empty array - } - } - return []; -}; - -/** - * Sorts pinned menu items according to user-defined order. - * @param menus - Array of menu item objects. - * @param pinnedStates - Object mapping menu item keys to pinned state (boolean). - * @param pinnedOrder - Array of menu item names in desired order. - * @returns Array of sorted pinned menu items. - */ -const getSortedPinnedItems = ( - menus: PinnedMenuItemsProps["menuItems"], - pinnedStates: Record, - pinnedOrder: string[] -) => { - const pinned = menus.filter(({ name }) => pinnedStates[createPinnedKey(name)]); - - return pinned.sort((a, b) => { - const aIdx = pinnedOrder.indexOf(a.name); - const bIdx = pinnedOrder.indexOf(b.name); - return ( - (aIdx === -1 ? Number.MAX_SAFE_INTEGER : aIdx) - - (bIdx === -1 ? Number.MAX_SAFE_INTEGER : bIdx) - ); - }); -}; - -/** - * Renders a group of pinned menu items in the admin UI. - * - * - Uses local storage to determine which menu items are pinned and their order. - * - Only displays the group if there are pinned items. - * - * @param props.menuItems - Array of menu item objects from admin config. - * @returns React fragment containing the "Pinned" menu group and its items, or null if none are pinned. - * - * @example - * - */ -export const PinnedMenuItems = ({ menuItems }: PinnedMenuItemsProps) => { - const rawPinnedOrder = useLocalStorageValue(PINNED_ORDER_KEY); - const [pinnableMenus, pinnableKeys, pinnedOrder] = useMemo(() => { - const menus = getPinnableMenus(menuItems); - const keys = getPinnableKeys(menus); - const order = parsePinnedOrder(rawPinnedOrder); - return [menus, keys, order]; - }, [menuItems, rawPinnedOrder]); - const pinnedStates = useLocalStorageValues(pinnableKeys); - - const pinnedItems = useMemo( - () => getSortedPinnedItems(pinnableMenus, pinnedStates, pinnedOrder), - [pinnableMenus, pinnedStates, pinnedOrder] - ); - - if (!pinnedItems.length) { - return null; - } - - return ( - <> - {pinnedItems.map(m => ( - - - {m.element} - - - ))} -
        - -
        - - ); -}; diff --git a/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx b/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx index 0d20dd11533..584bcbfa642 100644 --- a/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx +++ b/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx @@ -1,6 +1,5 @@ import React from "react"; import type { MenuConfig } from "@webiny/app-admin/config/AdminConfig/Menu.js"; -import { PinnableMenuItem } from "./PinnableMenuItem.js"; export interface MenusProps { menus: MenuConfig[]; @@ -34,25 +33,17 @@ export const SidebarMenuItems = (props: MenusProps) => { return null; } + const key = [m.parent, m.name].join("-"); + const hasChildMenus = allMenus.some(menu => menu.parent === m.name); if (hasChildMenus) { return React.cloneElement( m.element, - { key: m.parent + m.name }, + { key }, ); } - const menuItem = React.cloneElement(m.element, { key: m.parent + m.name }); - - if (m.pinnable) { - return ( - - {menuItem} - - ); - } - - return menuItem; + return React.cloneElement(m.element, { key }); }); }; diff --git a/packages/app-admin-ui/src/Navigation/SidebarProvider.tsx b/packages/app-admin-ui/src/Navigation/SidebarProvider.tsx index ddab422b24c..882239c7c90 100644 --- a/packages/app-admin-ui/src/Navigation/SidebarProvider.tsx +++ b/packages/app-admin-ui/src/Navigation/SidebarProvider.tsx @@ -1,14 +1,39 @@ -import React from "react"; +import React, { useMemo } from "react"; import { SidebarProvider as AdminUiSidebar } from "@webiny/admin-ui"; +import { useLocalStorage, useLocalStorageValue } from "@webiny/app"; interface NavigationProviderProps { children?: React.ReactNode; } +const PINNED_ITEMS_KEY = "navigation/pinned/items"; + export const SidebarProvider = (Component: React.ComponentType) => { return function SidebarProvider(props: NavigationProviderProps) { + const rawPinnedItems = useLocalStorageValue(PINNED_ITEMS_KEY); + const { set } = useLocalStorage(); + + const pinnedItems = useMemo(() => { + try { + if (Array.isArray(rawPinnedItems)) { + return rawPinnedItems; + } + if (typeof rawPinnedItems === "string") { + const parsed = JSON.parse(rawPinnedItems); + return Array.isArray(parsed) ? parsed : []; + } + } catch { + // Ignore parse errors + } + return []; + }, [rawPinnedItems]); + + const setPinnedItems = (items: string[]) => { + set(PINNED_ITEMS_KEY, JSON.stringify(items)); + }; + return ( - + ); diff --git a/packages/app-admin-users-cognito/src/Cognito.tsx b/packages/app-admin-users-cognito/src/Cognito.tsx index f49bdc60677..c2ad3da98f4 100644 --- a/packages/app-admin-users-cognito/src/Cognito.tsx +++ b/packages/app-admin-users-cognito/src/Cognito.tsx @@ -56,8 +56,13 @@ const CognitoIdP = (props: CognitoProps) => { } + element={ + + } /> diff --git a/packages/app-admin/src/base/Base/Menus.tsx b/packages/app-admin/src/base/Base/Menus.tsx index ed8b206e60f..8d8dd632999 100644 --- a/packages/app-admin/src/base/Base/Menus.tsx +++ b/packages/app-admin/src/base/Base/Menus.tsx @@ -27,6 +27,7 @@ export const Menus = React.memo(() => { {({ showFileManager }) => ( { { } + element={ + + } /> diff --git a/packages/app-headless-cms-workflows/src/Components/CmsWorkflows/CmsWorkflowsEditorView.tsx b/packages/app-headless-cms-workflows/src/Components/CmsWorkflows/CmsWorkflowsEditorView.tsx index 63488ea01d3..a3904ba2e23 100644 --- a/packages/app-headless-cms-workflows/src/Components/CmsWorkflows/CmsWorkflowsEditorView.tsx +++ b/packages/app-headless-cms-workflows/src/Components/CmsWorkflows/CmsWorkflowsEditorView.tsx @@ -29,10 +29,10 @@ export const CmsWorkflowsEditorMenu = () => { diff --git a/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx b/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx index 6f501dd0268..8fd2784250f 100644 --- a/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx +++ b/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx @@ -23,8 +23,13 @@ const CmsContentModelsMenu = ({ canAccess }: ChildMenuProps) => { } + element={ + + } /> ); }; @@ -39,9 +44,12 @@ const CmsContentGroupsMenu = ({ canAccess }: ChildMenuProps) => { + } /> ); diff --git a/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx b/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx index eb6fe95f770..4fe0e8d91cf 100644 --- a/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx +++ b/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx @@ -35,9 +35,9 @@ export const GroupContentModels = ({ group }: { group: CmsGroup }) => { { } + element={ + + } /> diff --git a/packages/app-security-access-management/src/Extension.tsx b/packages/app-security-access-management/src/Extension.tsx index 490fb75ee37..4715bd92844 100644 --- a/packages/app-security-access-management/src/Extension.tsx +++ b/packages/app-security-access-management/src/Extension.tsx @@ -62,8 +62,13 @@ const AccessManagementExtension = () => { } + element={ + + } /> @@ -71,9 +76,12 @@ const AccessManagementExtension = () => { + } /> @@ -83,9 +91,12 @@ const AccessManagementExtension = () => { + } /> diff --git a/packages/app-website-builder/src/Extension.tsx b/packages/app-website-builder/src/Extension.tsx index 864d97a6cf1..a427ff1d0a0 100644 --- a/packages/app-website-builder/src/Extension.tsx +++ b/packages/app-website-builder/src/Extension.tsx @@ -42,8 +42,7 @@ export const Extension = () => { } + element={} /> @@ -53,9 +52,12 @@ export const Extension = () => { + } /> } /> @@ -65,11 +67,11 @@ export const Extension = () => { } /> @@ -82,19 +84,9 @@ export const Extension = () => { /> - } - /> + } /> - } - /> + } /> @@ -104,10 +96,10 @@ export const Extension = () => { const SettingsMenuItem = () => { const { showSettingsDialog } = useSettingsDialog(); - return ; + return ; }; const IntegrationsMenuItem = () => { const { showIntegrationsDialog } = useIntegrationsDialog(); - return ; + return ; }; diff --git a/packages/app-workflows/src/Components/AdminConfig/WorkflowsMenu.tsx b/packages/app-workflows/src/Components/AdminConfig/WorkflowsMenu.tsx index f1cdc79dfc5..b16b27d2579 100644 --- a/packages/app-workflows/src/Components/AdminConfig/WorkflowsMenu.tsx +++ b/packages/app-workflows/src/Components/AdminConfig/WorkflowsMenu.tsx @@ -12,7 +12,6 @@ export const WorkflowsMenu = () => { return ( { } text={"Content Reviews"} to={router.getLink(Routes.Workflows.ContentReviews)} + pinnable={true} /> } /> diff --git a/packages/icons/src/extraIcons/push_pin_off.svg b/packages/icons/src/extraIcons/push_pin_off.svg index 1dd0e38a219..8208a4d730b 100644 --- a/packages/icons/src/extraIcons/push_pin_off.svg +++ b/packages/icons/src/extraIcons/push_pin_off.svg @@ -1,5 +1,5 @@ + viewBox="0 0 24 24">