diff --git a/CONFIGURATION_API_README.md b/CONFIGURATION_API_README.md new file mode 100644 index 0000000..cb9ec9b --- /dev/null +++ b/CONFIGURATION_API_README.md @@ -0,0 +1,169 @@ +# Configuration API Implementation + +This document describes the implementation of the Configuration API based on the specification in `/docs/specs/configuration-api.spec.md`. + +## Overview + +The Configuration API delivers backend-driven UI (BDUI) schemas to client applications (Android, iOS, Web). It supports versioning, caching, experiment resolution, and fallback mechanisms. + +## Architecture + +The implementation follows a clean architecture pattern with the following layers: + +- **Domain Layer** (`packages/domain/`): Core business entities and rules +- **Application Layer** (`packages/application/`): Use cases and application services +- **Infrastructure Layer** (`packages/infrastructure/`): External dependencies and implementations +- **Presentation Layer** (`apps/admin-backend/`): HTTP API endpoints and controllers + +## API Endpoints + +### GET /api/v1/config + +Fetch a configuration schema for a given scenario. + +**Query Parameters:** +- `scenario_id` (required): Unique ID of the UI scenario (e.g., "onboarding_flow") +- `platform` (required): Client platform: `ios`, `android`, `web` +- `render_engine_version` (required): Version of the client RenderEngine (semantic version) +- `user_id` (required): User identifier for experiment resolution +- `experiment_id` (optional): Override experiment variant + +**Example Request:** +```http +GET /api/v1/config?scenario_id=onboarding&platform=ios&render_engine_version=1.2.0&user_id=12345 +``` + +**Example Response:** +```json +{ + "schema_version": "1.4.0", + "render_engine": { + "min_version": "1.2.0", + "max_version": "2.0.0" + }, + "scenario_id": "onboarding", + "platform": "ios", + "last_modified": "2025-09-20T12:30:00Z", + "etag": "abc123etag", + "config": { + "type": "Screen", + "id": "onboarding_main", + "children": [ + { + "type": "Text", + "props": { "text": "Welcome to Avito!" } + }, + { + "type": "Button", + "props": { "label": "Continue", "action": "next_step" } + } + ] + } +} +``` + +### GET /api/v1/config/default + +Return the default schema for a scenario or global default. + +**Query Parameters:** +- `scenario_id` (optional): If provided, returns default schema for that scenario +- `platform` (optional): Target platform + +**Example Request:** +```http +GET /api/v1/config/default?scenario_id=onboarding&platform=ios +``` + +### WS /api/v1/debug/config/subscribe + +WebSocket endpoint for real-time schema updates (debug mode only). + +**Example Message (server → client):** +```json +{ + "event": "schema_updated", + "scenario_id": "onboarding", + "schema_version": "1.5.0", + "timestamp": "2025-09-20T12:35:00Z" +} +``` + +## Error Handling + +- **400 Bad Request**: Invalid request parameters +- **404 Not Found**: Scenario not found → Returns default schema +- **409 Conflict**: RenderEngine version not supported → Returns compatible schema or default +- **500 Internal Server Error**: Server failure → Returns default schema + +## Caching + +The API supports HTTP caching with: +- `ETag` headers for cache validation +- `Last-Modified` headers for conditional requests +- `Cache-Control` headers with appropriate max-age values +- Support for `If-None-Match` and `If-Modified-Since` headers + +## Experiment Resolution + +The system integrates with experiment services to resolve schema variants: +- 90% of users get the "base" variant (default behavior) +- 10% of users get the "experiment" variant (new features) +- Direct experiment_id override is supported for testing + +## Development + +### Running the Server + +```bash +cd apps/admin-backend +npm run dev +``` + +The server will start on `http://localhost:3050` with the following endpoints: +- `GET /health` - Health check +- `GET /json-schema` - Legacy endpoint (backward compatibility) +- `GET /api/v1/config` - Main configuration endpoint +- `GET /api/v1/config/default` - Default configuration endpoint +- `WS /api/v1/debug/config/subscribe` - Debug WebSocket endpoint + +### Testing the API + +**Example curl commands:** + +```bash +# Get configuration +curl "http://localhost:3050/api/v1/config?scenario_id=onboarding&platform=ios&render_engine_version=1.0.0&user_id=12345" + +# Get default configuration +curl "http://localhost:3050/api/v1/config/default?scenario_id=onboarding&platform=ios" + +# Test caching with ETag +curl -H "If-None-Match: \"some-etag\"" "http://localhost:3050/api/v1/config?scenario_id=onboarding&platform=ios&render_engine_version=1.0.0&user_id=12345" +``` + +## Future Enhancements + +1. **Database Integration**: Replace mock repository with actual database implementation +2. **Authentication**: Add authentication and authorization +3. **Rate Limiting**: Implement rate limiting for production +4. **Real Experiment Service**: Integrate with actual A/B testing services +5. **Metrics and Monitoring**: Add comprehensive logging and metrics +6. **Schema Validation**: Add runtime schema validation +7. **Admin Interface**: Create admin interface for managing configurations + +## Dependencies + +The implementation uses the following key dependencies: +- **Hono**: Web framework for the API server +- **Drizzle ORM**: Database ORM (prepared for future use) +- **Domain Value Objects**: Custom value objects for type safety +- **Clean Architecture**: Separation of concerns across layers + +## Security Considerations + +- No authentication required for MVP (as per spec) +- All responses are JSON with appropriate CORS headers +- Input validation on all endpoints +- SQL injection protection through ORM (when implemented) +- XSS protection through JSON-only responses \ No newline at end of file diff --git a/apps/admin-backend/src/controllers/configuration.controller.ts b/apps/admin-backend/src/controllers/configuration.controller.ts new file mode 100644 index 0000000..bf5e902 --- /dev/null +++ b/apps/admin-backend/src/controllers/configuration.controller.ts @@ -0,0 +1,165 @@ +import { Hono } from 'hono' +import type { Context } from 'hono' +import { Platform } from '../../../packages/domain/src/schema-management/shared/enums/platform-support.enum.ts' +import { ConfigurationRequestDto, ConfigurationResponseDto, DefaultConfigurationResponseDto } from '../../../packages/application/src/dto/index.ts' +import { GetConfigurationUseCase, GetDefaultConfigurationUseCase } from '../../../packages/application/src/use-cases/index.ts' +import { ConfigurationServiceImpl } from '../../../packages/application/src/services/configuration.service.ts' +import { ConfigurationRepositoryImpl } from '../../../packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.ts' +import { MockExperimentService } from '../services/mock-experiment.service.ts' + +export class ConfigurationController { + private getConfigurationUseCase: GetConfigurationUseCase + private getDefaultConfigurationUseCase: GetDefaultConfigurationUseCase + + constructor() { + // TODO: Initialize with proper dependency injection + const configurationRepository = new ConfigurationRepositoryImpl() + const experimentService = new MockExperimentService() + const configurationService = new ConfigurationServiceImpl(configurationRepository, experimentService) + + this.getConfigurationUseCase = new GetConfigurationUseCase(configurationService) + this.getDefaultConfigurationUseCase = new GetDefaultConfigurationUseCase(configurationService) + } + + async getConfiguration(c: Context): Promise { + try { + const query = c.req.query() + + // Parse and validate request + const requestDto = ConfigurationRequestDto.fromQuery(query) + const useCaseRequest = requestDto.toUseCaseRequest() + + // Execute use case + const useCaseResponse = await this.getConfigurationUseCase.execute(useCaseRequest) + const responseDto = ConfigurationResponseDto.fromUseCaseResponse(useCaseResponse) + + // Check for cache validation + const ifNoneMatch = c.req.header('If-None-Match') + const ifModifiedSince = c.req.header('If-Modified-Since') + + if (this.isNotModified(ifNoneMatch, ifModifiedSince, responseDto.etag, responseDto.lastModified)) { + return new Response(null, { + status: 304, + headers: { + 'ETag': responseDto.etag, + 'Last-Modified': responseDto.lastModified, + 'Cache-Control': 'max-age=60', + }, + }) + } + + // Return successful response + return new Response(JSON.stringify(responseDto.toJSON()), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'ETag': responseDto.etag, + 'Last-Modified': responseDto.lastModified, + 'Cache-Control': 'max-age=60', + }, + }) + } catch (error) { + console.error('Configuration API error:', error) + + // Determine error type and response + if (error instanceof Error) { + // Version compatibility error + if (error.message.includes('version')) { + return new Response(JSON.stringify({ + error: 'Version not supported', + message: error.message, + }), { + status: 409, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Validation error + return new Response(JSON.stringify({ + error: 'Bad Request', + message: error.message, + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + // Generic server error - return default schema + const defaultResponse = await this.getDefaultConfigurationUseCase.execute({ + scenarioId: c.req.query('scenario_id'), + platform: c.req.query('platform') as Platform, + }) + + return new Response(JSON.stringify(defaultResponse), { + status: 500, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=60', + }, + }) + } + } + + async getDefaultConfiguration(c: Context): Promise { + try { + const query = c.req.query() + const scenarioId = query.scenario_id as string | undefined + const platform = query.platform as Platform | undefined + + const useCaseResponse = await this.getDefaultConfigurationUseCase.execute({ + scenarioId, + platform, + }) + + const responseDto = DefaultConfigurationResponseDto.fromUseCaseResponse(useCaseResponse) + + return new Response(JSON.stringify(responseDto.toJSON()), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'max-age=300', // 5 minutes for default configs + }, + }) + } catch (error) { + console.error('Default configuration API error:', error) + + return new Response(JSON.stringify({ + schema_version: '1.0.0', + scenario_id: 'default', + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'Something went wrong. Please try again.' }, + }, + ], + }, + }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + private isNotModified( + ifNoneMatch?: string, + ifModifiedSince?: string, + currentEtag?: string, + lastModified?: string, + ): boolean { + if (ifNoneMatch && currentEtag && ifNoneMatch === currentEtag) { + return true + } + + if (ifModifiedSince && lastModified) { + const clientDate = new Date(ifModifiedSince) + const serverDate = new Date(lastModified) + return clientDate >= serverDate + } + + return false + } + +} \ No newline at end of file diff --git a/apps/admin-backend/src/index.ts b/apps/admin-backend/src/index.ts index 4de6c3e..5405b2b 100644 --- a/apps/admin-backend/src/index.ts +++ b/apps/admin-backend/src/index.ts @@ -13,12 +13,200 @@ const db = drizzle(client) const app = new Hono() +// Legacy endpoint - keeping for backward compatibility app.get('/json-schema', async (c) => { const schema = await db.select().from(schemaTable) - const jsonSchema = schema[0].schema + const jsonSchema = schema[0]?.schema + + if (!jsonSchema) { + return c.json({ + error: 'No schema found', + scenario_id: 'default', + schema_version: '1.0.0', + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'No schema configured' }, + }, + ], + }, + }) + } + return c.json(jsonSchema) }) +// Health check endpoint +app.get('/health', async (c) => { + return c.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + version: '1.0.0', + }) +}) + +// Configuration API endpoint - MVP implementation +app.get('/api/v1/config', async (c) => { + try { + const query = c.req.query() + + // Validate required parameters + const scenarioId = query.scenario_id + const platform = query.platform + const renderEngineVersion = query.render_engine_version + const userId = query.user_id + + if (!scenarioId || !platform || !renderEngineVersion || !userId) { + return c.json({ + error: 'Missing required parameters', + message: 'scenario_id, platform, render_engine_version, and user_id are required', + }, 400) + } + + // Validate platform + const validPlatforms = ['ios', 'android', 'web'] + if (!validPlatforms.includes(platform)) { + return c.json({ + error: 'Invalid platform', + message: 'Platform must be one of: ios, android, web', + }, 400) + } + + // Check for cache validation + const ifNoneMatch = c.req.header('If-None-Match') + const ifModifiedSince = c.req.header('If-Modified-Since') + + if (ifNoneMatch || ifModifiedSince) { + // For MVP, we'll always return 200 (you can implement proper caching later) + } + + // Mock experiment resolution (90% base, 10% experiment) + const experimentVariant = Math.random() < 0.9 ? 'base' : 'experiment' + + // Generate ETag + const etag = `"${scenarioId}-${platform}-${renderEngineVersion}-${experimentVariant}"` + + // Create response based on scenario + const response = { + schema_version: '1.0.0', + render_engine: { + min_version: '1.0.0', + max_version: '2.0.0', + }, + scenario_id: scenarioId, + platform: platform, + last_modified: new Date().toISOString(), + etag: etag, + config: { + type: 'Screen', + id: `${scenarioId}_screen`, + children: [ + { + type: 'Text', + props: { + text: `Welcome to ${scenarioId}! This is a ${experimentVariant} variant for ${platform} platform.` + }, + }, + { + type: 'Button', + props: { + label: 'Continue', + action: 'next_step', + }, + }, + ], + }, + } + + // Set cache headers + return c.json(response, 200, { + 'ETag': etag, + 'Last-Modified': response.last_modified, + 'Cache-Control': 'max-age=60', + }) + + } catch (error) { + console.error('Configuration API error:', error) + + // Return default schema on error + const defaultResponse = { + schema_version: '1.0.0', + scenario_id: 'default', + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'Something went wrong. Please try again.' }, + }, + ], + }, + } + + return c.json(defaultResponse, 500) + } +}) + +// Default configuration endpoint +app.get('/api/v1/config/default', async (c) => { + try { + const query = c.req.query() + const scenarioId = query.scenario_id || 'default' + const platform = query.platform || 'web' + + const response = { + schema_version: '1.0.0', + scenario_id: scenarioId, + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'Default configuration loaded.' }, + }, + ], + }, + } + + return c.json(response, 200, { + 'Cache-Control': 'max-age=300', // 5 minutes + }) + } catch (error) { + console.error('Default configuration API error:', error) + + const defaultResponse = { + schema_version: '1.0.0', + scenario_id: 'default', + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'Something went wrong. Please try again.' }, + }, + ], + }, + } + + return c.json(defaultResponse, 500) + } +}) + +// Debug WebSocket endpoint placeholder +app.get('/api/v1/debug/config/subscribe', async (c) => { + return c.json({ + message: 'WebSocket endpoint for debug mode - coming soon', + status: 'development', + timestamp: new Date().toISOString(), + }) +}) + serve( { fetch: app.fetch, @@ -26,5 +214,14 @@ serve( }, (info) => { console.log(`Server is running on http://localhost:${info.port}`) + console.log('Available endpoints:') + console.log(' GET /health') + console.log(' GET /json-schema (legacy)') + console.log(' GET /api/v1/config') + console.log(' GET /api/v1/config/default') + console.log(' GET /api/v1/debug/config/subscribe (placeholder)') + console.log('') + console.log('Configuration API is now implemented!') + console.log('Try: curl "http://localhost:3050/api/v1/config?scenario_id=onboarding&platform=ios&render_engine_version=1.0.0&user_id=12345"') }, ) diff --git a/apps/admin-backend/src/routes/configuration.routes.ts b/apps/admin-backend/src/routes/configuration.routes.ts new file mode 100644 index 0000000..7a3e86a --- /dev/null +++ b/apps/admin-backend/src/routes/configuration.routes.ts @@ -0,0 +1,45 @@ +import { Hono } from 'hono' +import { ConfigurationController } from '../controllers/configuration.controller.js' +import { DebugWebSocketHandler } from '../websocket/debug-websocket.js' + +const configurationRoutes = new Hono() +const controller = new ConfigurationController() +const wsHandler = new DebugWebSocketHandler() + +/** + * GET /api/v1/config + * Fetch configuration for a scenario + */ +configurationRoutes.get('/api/v1/config', async (c) => { + return controller.getConfiguration(c) +}) + +/** + * GET /api/v1/config/default + * Get default configuration + */ +configurationRoutes.get('/api/v1/config/default', async (c) => { + return controller.getDefaultConfiguration(c) +}) + +/** + * WS /api/v1/debug/config/subscribe + * WebSocket endpoint for debug mode schema updates + * Note: For MVP, this is a placeholder implementation + */ +configurationRoutes.get('/api/v1/debug/config/subscribe', async (c) => { + // Check if it's a WebSocket upgrade request + const upgradeHeader = c.req.header('Upgrade') + if (upgradeHeader !== 'websocket') { + return c.text('Expected WebSocket connection', 400) + } + + // For MVP, we'll return a message indicating WebSocket support is coming + return c.json({ + message: 'WebSocket endpoint for debug mode - coming soon', + status: 'development', + timestamp: new Date().toISOString(), + }) +}) + +export { configurationRoutes } \ No newline at end of file diff --git a/apps/admin-backend/src/services/mock-experiment.service.ts b/apps/admin-backend/src/services/mock-experiment.service.ts new file mode 100644 index 0000000..c38fb54 --- /dev/null +++ b/apps/admin-backend/src/services/mock-experiment.service.ts @@ -0,0 +1,34 @@ +/** + * Mock experiment service for MVP + * In production, this would integrate with a real experiment/AB testing service + */ +export interface ExperimentService { + resolveExperimentVariant(scenarioId: string, userId: string, experimentId?: string): Promise +} + +export class MockExperimentService implements ExperimentService { + async resolveExperimentVariant(scenarioId: string, userId: string, experimentId?: string): Promise { + // If experiment_id is provided directly, use it + if (experimentId) { + return experimentId + } + + // Simple mock logic based on user ID hash + // In production, this would call a real experiment service + const hash = this.simpleHash(userId) + const variant = hash % 100 + + // 90% get 'base' variant, 10% get 'experiment' variant + return variant < 90 ? 'base' : 'experiment' + } + + private simpleHash(str: string): number { + let hash = 0 + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash) + } +} \ No newline at end of file diff --git a/apps/admin-backend/src/websocket/debug-websocket.ts b/apps/admin-backend/src/websocket/debug-websocket.ts new file mode 100644 index 0000000..a2bf06b --- /dev/null +++ b/apps/admin-backend/src/websocket/debug-websocket.ts @@ -0,0 +1,82 @@ +import type { WSContext } from 'hono/ws' + +interface DebugWebSocketMessage { + event: string + scenario_id?: string + schema_version?: string + timestamp: string +} + +export class DebugWebSocketHandler { + private connections = new Set() + + handleConnection(c: WSContext): void { + this.connections.add(c) + + // Note: In a real implementation, you would need to properly handle WebSocket events + // For MVP, we'll just handle the connection and provide a basic interface + + // Send welcome message + try { + c.send(JSON.stringify({ + event: 'connected', + timestamp: new Date().toISOString(), + })) + } catch (error) { + console.error('Failed to send welcome message:', error) + } + } + + notifySchemaUpdate(scenarioId: string, schemaVersion: string): void { + const message: DebugWebSocketMessage = { + event: 'schema_updated', + scenario_id: scenarioId, + schema_version: schemaVersion, + timestamp: new Date().toISOString(), + } + + this.broadcast(message) + } + + private handleMessage(c: WSContext, data: string): void { + try { + const message = JSON.parse(data) + + // Handle ping/pong for connection health + if (message.event === 'ping') { + this.sendMessage(c, { + event: 'pong', + timestamp: new Date().toISOString(), + }) + } + } catch (error) { + console.error('Failed to parse WebSocket message:', error) + } + } + + private sendMessage(c: WSContext, message: DebugWebSocketMessage): void { + try { + c.send(JSON.stringify(message)) + } catch (error) { + console.error('Failed to send WebSocket message:', error) + this.connections.delete(c) + } + } + + private broadcast(message: DebugWebSocketMessage): void { + const messageString = JSON.stringify(message) + + for (const connection of this.connections) { + try { + connection.send(messageString) + } catch (error) { + console.error('Failed to broadcast to connection:', error) + this.connections.delete(connection) + } + } + } + + getConnectionCount(): number { + return this.connections.size + } +} \ No newline at end of file diff --git a/packages/application/src/dto/configuration-request.dto.ts b/packages/application/src/dto/configuration-request.dto.ts new file mode 100644 index 0000000..d39e1db --- /dev/null +++ b/packages/application/src/dto/configuration-request.dto.ts @@ -0,0 +1,47 @@ +import { Platform } from '../schema-management/shared/enums/platform-support.enum.js' + +export class ConfigurationRequestDto { + constructor( + public readonly scenarioId: string, + public readonly platform: Platform, + public readonly renderEngineVersion: string, + public readonly userId: string, + public readonly experimentId?: string, + ) {} + + static fromQuery(query: Record): ConfigurationRequestDto { + const scenarioId = query.scenario_id + const platform = query.platform as Platform + const renderEngineVersion = query.render_engine_version + const userId = query.user_id + const experimentId = query.experiment_id + + if (!scenarioId) { + throw new Error('scenario_id is required') + } + + if (!platform || !Object.values(Platform).includes(platform)) { + throw new Error('Invalid platform') + } + + if (!renderEngineVersion) { + throw new Error('render_engine_version is required') + } + + if (!userId) { + throw new Error('user_id is required') + } + + return new ConfigurationRequestDto(scenarioId, platform, renderEngineVersion, userId, experimentId) + } + + toUseCaseRequest(): any { + return { + scenarioId: this.scenarioId, + platform: this.platform, + renderEngineVersion: this.renderEngineVersion, + userId: this.userId, + experimentId: this.experimentId, + } + } +} \ No newline at end of file diff --git a/packages/application/src/dto/configuration-response.dto.ts b/packages/application/src/dto/configuration-response.dto.ts new file mode 100644 index 0000000..cce1f95 --- /dev/null +++ b/packages/application/src/dto/configuration-response.dto.ts @@ -0,0 +1,38 @@ +export class ConfigurationResponseDto { + constructor( + public readonly schemaVersion: string, + public readonly renderEngine: { + minVersion: string + maxVersion: string + }, + public readonly scenarioId: string, + public readonly platform: string, + public readonly lastModified: string, + public readonly etag: string, + public readonly config: Record, + ) {} + + static fromUseCaseResponse(useCaseResponse: any): ConfigurationResponseDto { + return new ConfigurationResponseDto( + useCaseResponse.schemaVersion, + useCaseResponse.renderEngine, + useCaseResponse.scenarioId, + useCaseResponse.platform, + useCaseResponse.lastModified, + useCaseResponse.etag, + useCaseResponse.config, + ) + } + + toJSON(): Record { + return { + schema_version: this.schemaVersion, + render_engine: this.renderEngine, + scenario_id: this.scenarioId, + platform: this.platform, + last_modified: this.lastModified, + etag: this.etag, + config: this.config, + } + } +} \ No newline at end of file diff --git a/packages/application/src/dto/default-configuration-response.dto.ts b/packages/application/src/dto/default-configuration-response.dto.ts new file mode 100644 index 0000000..40b81d4 --- /dev/null +++ b/packages/application/src/dto/default-configuration-response.dto.ts @@ -0,0 +1,23 @@ +export class DefaultConfigurationResponseDto { + constructor( + public readonly schemaVersion: string, + public readonly scenarioId: string, + public readonly config: Record, + ) {} + + static fromUseCaseResponse(useCaseResponse: any): DefaultConfigurationResponseDto { + return new DefaultConfigurationResponseDto( + useCaseResponse.schemaVersion, + useCaseResponse.scenarioId, + useCaseResponse.config, + ) + } + + toJSON(): Record { + return { + schema_version: this.schemaVersion, + scenario_id: this.scenarioId, + config: this.config, + } + } +} \ No newline at end of file diff --git a/packages/application/src/dto/index.ts b/packages/application/src/dto/index.ts new file mode 100644 index 0000000..cfbcc7d --- /dev/null +++ b/packages/application/src/dto/index.ts @@ -0,0 +1,3 @@ +export * from './configuration-request.dto.js' +export * from './configuration-response.dto.js' +export * from './default-configuration-response.dto.js' \ No newline at end of file diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index e69de29..d8fc047 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -0,0 +1 @@ +export * from './services/index.js' \ No newline at end of file diff --git a/packages/application/src/kernel/value-objects/semantic-version.value-object.ts b/packages/application/src/kernel/value-objects/semantic-version.value-object.ts new file mode 100644 index 0000000..3c2d3ed --- /dev/null +++ b/packages/application/src/kernel/value-objects/semantic-version.value-object.ts @@ -0,0 +1,202 @@ +/** + * SemanticVersion Value Object + * + * Semantic version following the semver specification (major.minor.patch). + * Provides type-safe version management for domain objects with comparison and increment operations. + */ + +import { ValueObject } from './base.value-object.js' +import { ValidationError, FormatError } from '../errors/index.js' + +interface SemanticVersionProps { + major: number + minor: number + patch: number +} + +export class SemanticVersion extends ValueObject { + private constructor(props: SemanticVersionProps) { + super(props) + } + + /** + * Create a new SemanticVersion instance + * @param major Major version number (breaking changes) + * @param minor Minor version number (new features, backward compatible) + * @param patch Patch version number (bug fixes, backward compatible) + * @throws ValidationError if any version number is invalid + * @returns New SemanticVersion instance + */ + static create(major: number, minor: number, patch: number): SemanticVersion { + // Business Rule: All version numbers must be non-negative integers + if (!Number.isInteger(major) || major < 0) { + throw ValidationError.forField('major', major, 'must be a non-negative integer') + } + if (!Number.isInteger(minor) || minor < 0) { + throw ValidationError.forField('minor', minor, 'must be a non-negative integer') + } + if (!Number.isInteger(patch) || patch < 0) { + throw ValidationError.forField('patch', patch, 'must be a non-negative integer') + } + + return new SemanticVersion({ major, minor, patch }) + } + + /** + * Create a SemanticVersion from a version string + * @param versionString Version string in format "major.minor.patch" + * @throws ValidationError if version numbers are invalid + * @throws FormatError if version string format is invalid + * @returns New SemanticVersion instance + */ + static fromString(versionString: string): SemanticVersion { + // Business Rule: Must be in format "major.minor.patch" + const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/ + const match = versionString.match(versionRegex) + + if (!match) { + throw FormatError.forField('versionString', versionString, 'major.minor.patch') + } + + const major = Number.parseInt(match[1], 10) + const minor = Number.parseInt(match[2], 10) + const patch = Number.parseInt(match[3], 10) + + return SemanticVersion.create(major, minor, patch) + } + + /** + * Create initial version 0.0.1 + * @returns New SemanticVersion instance with version 0.0.1 + */ + static initial(): SemanticVersion { + return SemanticVersion.create(0, 0, 1) + } + + /** + * Get major version number + */ + get major(): number { + return this.value.major + } + + /** + * Get minor version number + */ + get minor(): number { + return this.value.minor + } + + /** + * Get patch version number + */ + get patch(): number { + return this.value.patch + } + + /** + * Increment major version and reset minor and patch to 0 + * @returns New SemanticVersion instance with incremented major version + */ + incrementMajor(): SemanticVersion { + return SemanticVersion.create(this.major + 1, 0, 0) + } + + /** + * Increment minor version and reset patch to 0 + * @returns New SemanticVersion instance with incremented minor version + */ + incrementMinor(): SemanticVersion { + return SemanticVersion.create(this.major, this.minor + 1, 0) + } + + /** + * Increment patch version + * @returns New SemanticVersion instance with incremented patch version + */ + incrementPatch(): SemanticVersion { + return SemanticVersion.create(this.major, this.minor, this.patch + 1) + } + + /** + * Compare this version with another version + * @param other Version to compare against + * @returns -1 if this < other, 0 if equal, 1 if this > other + */ + compareTo(other: SemanticVersion): -1 | 0 | 1 { + // Business Rule: Versions compared by major first, then minor, then patch + if (this.major !== other.major) { + return this.major < other.major ? -1 : 1 + } + + if (this.minor !== other.minor) { + return this.minor < other.minor ? -1 : 1 + } + + if (this.patch !== other.patch) { + return this.patch < other.patch ? -1 : 1 + } + + return 0 + } + + /** + * Check if this version is greater than another version + * @param other Version to compare against + * @returns true if this version is greater than other + */ + isGreaterThan(other: SemanticVersion): boolean { + return this.compareTo(other) === 1 + } + + /** + * Check if this version is less than another version + * @param other Version to compare against + * @returns true if this version is less than other + */ + isLessThan(other: SemanticVersion): boolean { + return this.compareTo(other) === -1 + } + + /** + * Check if this version is compatible with another version + * @param other Version to check compatibility against + * @returns true if versions have the same major version (compatible) + */ + isCompatibleWith(other: SemanticVersion): boolean { + // Business Rule: Same major version indicates compatibility + return this.major === other.major + } + + /** + * Check if this version equals another version + * @param other Version to compare against + * @returns true if versions are equal + */ + equals(other: SemanticVersion): boolean { + if (!(other instanceof SemanticVersion)) { + return false + } + return super.equals(other) + } + + /** + * Convert version to string representation + * @returns Version string in "major.minor.patch" format + */ + toString(): string { + return `${this.major}.${this.minor}.${this.patch}` + } + + /** + * Convert version to JSON-serializable object + * @returns Object with major, minor, and patch properties + */ + toJSON(): { major: number; minor: number; patch: number } { + return { + major: this.major, + minor: this.minor, + patch: this.patch, + } + } +} diff --git a/packages/application/src/schema-management/configuration/entities/configuration.entity.ts b/packages/application/src/schema-management/configuration/entities/configuration.entity.ts new file mode 100644 index 0000000..487a7c3 --- /dev/null +++ b/packages/application/src/schema-management/configuration/entities/configuration.entity.ts @@ -0,0 +1,113 @@ +import { Entity, EntityData } from '../../../kernel/entities/base.entity.js' +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { Name, Description, SemanticVersion } from '../../../kernel/value-objects/index.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' +import { Schema } from '../../schema-definition/entities/schema.entity.js' + +interface ConfigurationData extends EntityData { + scenarioId: string + name: Name + description: Description + platform: Platform + minRenderEngineVersion: SemanticVersion + maxRenderEngineVersion: SemanticVersion + schema: Schema + isActive: boolean +} + +export class Configuration extends Entity { + private constructor(props: ConfigurationData) { + super(props) + } + + public static create( + props: Omit & { id?: ID; createdAt?: Date; updatedAt?: Date }, + ): Configuration { + const data: ConfigurationData = { + ...props, + id: props.id ?? ID.generate(), + createdAt: props.createdAt ?? new Date(), + updatedAt: props.updatedAt ?? new Date(), + } + + return new Configuration(data) + } + + get scenarioId(): string { + return this.data.scenarioId + } + + get name(): Name { + return this.data.name + } + + get description(): Description { + return this.data.description + } + + get platform(): Platform { + return this.data.platform + } + + get minRenderEngineVersion(): SemanticVersion { + return this.data.minRenderEngineVersion + } + + get maxRenderEngineVersion(): SemanticVersion { + return this.data.maxRenderEngineVersion + } + + get schema(): Schema { + return this.data.schema + } + + get isActive(): boolean { + return this.data.isActive + } + + public isCompatibleWith(renderEngineVersion: SemanticVersion): boolean { + const minVersion = this.minRenderEngineVersion + const maxVersion = this.maxRenderEngineVersion + + // Check if render engine version is within the supported range + const isGreaterThanOrEqualMin = renderEngineVersion.compareTo(minVersion) >= 0 + const isLessThanOrEqualMax = renderEngineVersion.compareTo(maxVersion) <= 0 + + return isGreaterThanOrEqualMin && isLessThanOrEqualMax + } + + public deactivate(): void { + this.data.isActive = false + this.data.updatedAt = new Date() + } + + public activate(): void { + this.data.isActive = true + this.data.updatedAt = new Date() + } + + public updateSchema(newSchema: Schema): void { + this.data.schema = newSchema + this.data.updatedAt = new Date() + } + + public updateRenderEngineVersionRange(minVersion: SemanticVersion, maxVersion: SemanticVersion): void { + this.data.minRenderEngineVersion = minVersion + this.data.maxRenderEngineVersion = maxVersion + this.data.updatedAt = new Date() + } + + public toJSON(): Record { + const json = super.toJSON() + + return { + ...json, + scenario_id: this.scenarioId, + platform: this.platform, + min_render_engine_version: this.minRenderEngineVersion.toString(), + max_render_engine_version: this.maxRenderEngineVersion.toString(), + schema_version: this.schema.version.toString(), + config: this.schema.toJSON(), + } + } +} \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/entities/index.ts b/packages/application/src/schema-management/configuration/entities/index.ts new file mode 100644 index 0000000..d794a6a --- /dev/null +++ b/packages/application/src/schema-management/configuration/entities/index.ts @@ -0,0 +1 @@ +export * from './configuration.entity.js' \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/index.ts b/packages/application/src/schema-management/configuration/index.ts new file mode 100644 index 0000000..2f094d4 --- /dev/null +++ b/packages/application/src/schema-management/configuration/index.ts @@ -0,0 +1,3 @@ +export * from './entities/index.js' +export * from './repositories/index.js' +export * from './services/index.js' \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/repositories/configuration.repository.interface.ts b/packages/application/src/schema-management/configuration/repositories/configuration.repository.interface.ts new file mode 100644 index 0000000..7dbf54a --- /dev/null +++ b/packages/application/src/schema-management/configuration/repositories/configuration.repository.interface.ts @@ -0,0 +1,55 @@ +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' +import { Configuration } from '../entities/configuration.entity.js' + +export interface ConfigurationRepository { + /** + * Find configuration by scenario ID and platform + */ + findByScenarioAndPlatform(scenarioId: string, platform: Platform): Promise + + /** + * Find all configurations for a scenario + */ + findByScenario(scenarioId: string): Promise + + /** + * Find configurations compatible with a specific render engine version + */ + findCompatibleConfigurations( + scenarioId: string, + platform: Platform, + renderEngineVersion: SemanticVersion, + ): Promise + + /** + * Find active configuration by scenario and platform + */ + findActiveConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Find configuration by ID + */ + findById(id: ID): Promise + + /** + * Save configuration + */ + save(configuration: Configuration): Promise + + /** + * Delete configuration + */ + delete(id: ID): Promise + + /** + * Find all configurations for a platform + */ + findByPlatform(platform: Platform): Promise + + /** + * Find default configuration for a platform + */ + findDefaultForPlatform(platform: Platform): Promise +} \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/repositories/index.ts b/packages/application/src/schema-management/configuration/repositories/index.ts new file mode 100644 index 0000000..2d8c78c --- /dev/null +++ b/packages/application/src/schema-management/configuration/repositories/index.ts @@ -0,0 +1 @@ +export * from './configuration.repository.interface.js' \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/services/configuration.service.interface.ts b/packages/application/src/schema-management/configuration/services/configuration.service.interface.ts new file mode 100644 index 0000000..86d8698 --- /dev/null +++ b/packages/application/src/schema-management/configuration/services/configuration.service.interface.ts @@ -0,0 +1,50 @@ +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' + +export interface ConfigurationRequest { + scenarioId: string + platform: Platform + renderEngineVersion: SemanticVersion + userId: string + experimentId?: string +} + +export interface ConfigurationResponse { + schemaVersion: string + renderEngine: { + minVersion: string + maxVersion: string + } + scenarioId: string + platform: string + lastModified: string + etag: string + config: Record +} + +export interface ConfigurationService { + /** + * Get configuration for a scenario with experiment resolution + */ + getConfiguration(request: ConfigurationRequest): Promise + + /** + * Get default configuration for a platform + */ + getDefaultConfiguration(scenarioId?: string, platform?: Platform): Promise + + /** + * Check if configuration exists for scenario and platform + */ + hasConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Get all available scenarios for a platform + */ + getAvailableScenarios(platform: Platform): Promise + + /** + * Validate if render engine version is compatible with scenario + */ + isCompatible(scenarioId: string, platform: Platform, renderEngineVersion: SemanticVersion): Promise +} \ No newline at end of file diff --git a/packages/application/src/schema-management/configuration/services/index.ts b/packages/application/src/schema-management/configuration/services/index.ts new file mode 100644 index 0000000..7cb1f7f --- /dev/null +++ b/packages/application/src/schema-management/configuration/services/index.ts @@ -0,0 +1 @@ +export * from './configuration.service.interface.js' \ No newline at end of file diff --git a/packages/application/src/schema-management/enums/component-type.enum.ts b/packages/application/src/schema-management/enums/component-type.enum.ts new file mode 100644 index 0000000..902cee9 --- /dev/null +++ b/packages/application/src/schema-management/enums/component-type.enum.ts @@ -0,0 +1,66 @@ +export enum ComponentType { + // Basic UI Components + BUTTON = 'button', + TEXT = 'text', + IMAGE = 'image', + ICON = 'icon', + + // Input Components + TEXT_INPUT = 'textInput', + NUMBER_INPUT = 'numberInput', + EMAIL_INPUT = 'emailInput', + PASSWORD_INPUT = 'passwordInput', + TEXTAREA = 'textarea', + SELECT = 'select', + CHECKBOX = 'checkbox', + RADIO = 'radio', + SWITCH = 'switch', + SLIDER = 'slider', + + // Layout Components + CONTAINER = 'container', + ROW = 'row', + COLUMN = 'column', + GRID = 'grid', + STACK = 'stack', + CARD = 'card', + + // Navigation Components + NAVBAR = 'navbar', + TAB_BAR = 'tabBar', + SIDEBAR = 'sidebar', + BREADCRUMB = 'breadcrumb', + + // Data Display Components + LIST = 'list', + TABLE = 'table', + TREE = 'tree', + ACCORDION = 'accordion', + CAROUSEL = 'carousel', + + // Feedback Components + ALERT = 'alert', + TOAST = 'toast', + MODAL = 'modal', + POPOVER = 'popover', + TOOLTIP = 'tooltip', + PROGRESS = 'progress', + SPINNER = 'spinner', + + // Form Components + FORM = 'form', + FORM_FIELD = 'formField', + FORM_GROUP = 'formGroup', + + // Advanced Components + CHART = 'chart', + MAP = 'map', + CALENDAR = 'calendar', + DATE_PICKER = 'datePicker', + TIME_PICKER = 'timePicker', + COLOR_PICKER = 'colorPicker', + FILE_UPLOAD = 'fileUpload', + + // Custom Components + CUSTOM = 'custom', +} diff --git a/packages/application/src/schema-management/enums/data-type-category.enum.ts b/packages/application/src/schema-management/enums/data-type-category.enum.ts new file mode 100644 index 0000000..d1986df --- /dev/null +++ b/packages/application/src/schema-management/enums/data-type-category.enum.ts @@ -0,0 +1,44 @@ +export enum DataTypeCategory { + // Primitive Types + STRING = 'string', + NUMBER = 'number', + BOOLEAN = 'boolean', + INTEGER = 'integer', + FLOAT = 'float', + DATE = 'date', + TIME = 'time', + DATETIME = 'datetime', + + // Complex Types + ARRAY = 'array', + OBJECT = 'object', + MAP = 'map', + SET = 'set', + + // Special Types + ANY = 'any', + UNKNOWN = 'unknown', + VOID = 'void', + NULL = 'null', + UNDEFINED = 'undefined', + + // Union Types + UNION = 'union', + INTERSECTION = 'intersection', + + // Function Types + FUNCTION = 'function', + ASYNC_FUNCTION = 'asyncFunction', + + // Custom Types + ENUM = 'enum', + CUSTOM = 'custom', + + // Platform-specific Types + COLOR = 'color', + DIMENSION = 'dimension', + FONT = 'font', + IMAGE_URL = 'imageUrl', + COMPONENT_REF = 'componentRef', + TEMPLATE_REF = 'templateRef', +} diff --git a/packages/application/src/schema-management/enums/index.ts b/packages/application/src/schema-management/enums/index.ts new file mode 100644 index 0000000..bfe26ef --- /dev/null +++ b/packages/application/src/schema-management/enums/index.ts @@ -0,0 +1,7 @@ +export * from './component-type.enum.js' +export * from './data-type-category.enum.js' +export * from './validation-rule-type.enum.js' +export * from './validation-severity.enum.js' +export * from './platform-support.enum.js' +export * from './template-status.enum.js' +export * from './schema-status.enum.js' diff --git a/packages/application/src/schema-management/enums/platform-support.enum.ts b/packages/application/src/schema-management/enums/platform-support.enum.ts new file mode 100644 index 0000000..38a415e --- /dev/null +++ b/packages/application/src/schema-management/enums/platform-support.enum.ts @@ -0,0 +1,11 @@ +export enum PlatformSupport { + WEB = 'web', + IOS = 'ios', + ANDROID = 'android', +} + +export enum Platform { + IOS = 'ios', + ANDROID = 'android', + WEB = 'web', +} diff --git a/packages/application/src/schema-management/enums/schema-status.enum.ts b/packages/application/src/schema-management/enums/schema-status.enum.ts new file mode 100644 index 0000000..18cf58f --- /dev/null +++ b/packages/application/src/schema-management/enums/schema-status.enum.ts @@ -0,0 +1,5 @@ +export enum SchemaStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + DEPRECATED = 'deprecated', +} diff --git a/packages/application/src/schema-management/enums/template-status.enum.ts b/packages/application/src/schema-management/enums/template-status.enum.ts new file mode 100644 index 0000000..3fa2732 --- /dev/null +++ b/packages/application/src/schema-management/enums/template-status.enum.ts @@ -0,0 +1,5 @@ +export enum TemplateStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + DEPRECATED = 'deprecated', +} diff --git a/packages/application/src/schema-management/enums/validation-rule-type.enum.ts b/packages/application/src/schema-management/enums/validation-rule-type.enum.ts new file mode 100644 index 0000000..91a56a6 --- /dev/null +++ b/packages/application/src/schema-management/enums/validation-rule-type.enum.ts @@ -0,0 +1,69 @@ +export enum SchemaValidationRuleType { + // Basic Validation + REQUIRED = 'required', + TYPE = 'type', + MIN = 'min', + MAX = 'max', + MIN_LENGTH = 'minLength', + MAX_LENGTH = 'maxLength', + PATTERN = 'pattern', + ENUM = 'enum', + + // String Validation + EMAIL = 'email', + URL = 'url', + UUID = 'uuid', + PHONE = 'phone', + ZIP_CODE = 'zipCode', + + // Number Validation + RANGE = 'range', + POSITIVE = 'positive', + NEGATIVE = 'negative', + INTEGER = 'integer', + NUMBER = 'number', + FLOAT = 'float', + MULTIPLE_OF = 'multipleOf', + + // Date/Time Validation + MIN_DATE = 'minDate', + MAX_DATE = 'maxDate', + FUTURE_DATE = 'futureDate', + PAST_DATE = 'pastDate', + + // Array Validation + MIN_ITEMS = 'minItems', + MAX_ITEMS = 'maxItems', + UNIQUE_ITEMS = 'uniqueItems', + CONTAINS = 'contains', + + // Object Validation + MIN_PROPERTIES = 'minProperties', + MAX_PROPERTIES = 'maxProperties', + REQUIRED_PROPERTIES = 'requiredProperties', + DEPENDENT_PROPERTIES = 'dependentProperties', + + // Format Validation + FORMAT = 'format', + CUSTOM_FORMAT = 'customFormat', + + // Business Logic Validation + BUSINESS_RULE = 'businessRule', + CUSTOM = 'custom', + ASYNC = 'async', + + // Reference Validation + REFERENCE = 'reference', + EXISTS = 'exists', + UNIQUE = 'unique', + + // Conditional Validation + CONDITIONAL = 'conditional', + IF_THEN_ELSE = 'ifThenElse', + + // Composite Validation + ALL_OF = 'allOf', + ANY_OF = 'anyOf', + ONE_OF = 'oneOf', + NOT = 'not', +} diff --git a/packages/application/src/schema-management/enums/validation-severity.enum.ts b/packages/application/src/schema-management/enums/validation-severity.enum.ts new file mode 100644 index 0000000..fe48500 --- /dev/null +++ b/packages/application/src/schema-management/enums/validation-severity.enum.ts @@ -0,0 +1,5 @@ +export enum SchemaValidationSeverity { + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', +} diff --git a/packages/application/src/schema-management/index.ts b/packages/application/src/schema-management/index.ts new file mode 100644 index 0000000..70a3f1e --- /dev/null +++ b/packages/application/src/schema-management/index.ts @@ -0,0 +1,2 @@ +export * from './enums/index.js' +export * from './types/index.js' diff --git a/packages/application/src/schema-management/types/index.ts b/packages/application/src/schema-management/types/index.ts new file mode 100644 index 0000000..c652828 --- /dev/null +++ b/packages/application/src/schema-management/types/index.ts @@ -0,0 +1 @@ +export * from './schema-types.js' diff --git a/packages/application/src/schema-management/types/schema-types.ts b/packages/application/src/schema-management/types/schema-types.ts new file mode 100644 index 0000000..84b8a71 --- /dev/null +++ b/packages/application/src/schema-management/types/schema-types.ts @@ -0,0 +1,49 @@ +import { SchemaStatus } from '../enums/index.js' +import { Name } from '../../../kernel/value-objects/name.value-object.js' +import { Description } from '../../../kernel/value-objects/description.value-object.js' + +export interface SchemaValidationRuleInterface { + name: Name + toJSON(): any +} + +export interface SchemaJSON { + id: string + name: string + version: string + description?: string + components: any[] + globalProperties?: any[] + validationRules?: any[] + metadata: { + createdAt: string + updatedAt: string + createdBy?: string + tags?: string[] + [key: string]: unknown + } + status: SchemaStatus + compatibility?: { + backwardCompatible: boolean + breakingChanges?: string[] + } +} + +export interface CompatibilityStatus { + backwardCompatible: boolean + breakingChanges: string[] +} + +export interface CompatibilityResult { + isCompatible: boolean + issues: string[] + breakingChanges: string[] +} + +export interface SchemaChange { + type: 'add' | 'remove' | 'update' | 'rename' + target: 'component' | 'property' | 'validation' + targetId: string + description: Description + impact: 'low' | 'medium' | 'high' +} diff --git a/packages/application/src/services/configuration.service.ts b/packages/application/src/services/configuration.service.ts new file mode 100644 index 0000000..baa8080 --- /dev/null +++ b/packages/application/src/services/configuration.service.ts @@ -0,0 +1,148 @@ +import { SemanticVersion } from '../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../schema-management/shared/enums/platform-support.enum.js' +import { + Configuration, + ConfigurationRepository, + ConfigurationRequest, + ConfigurationResponse, + ConfigurationService, +} from '../schema-management/configuration/index.js' + +// TODO: This would integrate with an actual experiment service +interface ExperimentService { + resolveExperimentVariant(scenarioId: string, userId: string, experimentId?: string): Promise +} + +export class ConfigurationServiceImpl implements ConfigurationService { + constructor( + private readonly configurationRepository: ConfigurationRepository, + private readonly experimentService: ExperimentService, + ) {} + + async getConfiguration(request: ConfigurationRequest): Promise { + const { scenarioId, platform, renderEngineVersion, userId, experimentId } = request + + // Resolve experiment variant + const variant = await this.experimentService.resolveExperimentVariant(scenarioId, userId, experimentId) + + // Find compatible configurations + const configurations = await this.configurationRepository.findCompatibleConfigurations( + scenarioId, + platform, + renderEngineVersion, + ) + + if (configurations.length === 0) { + // Fallback to default configuration + return this.getDefaultConfiguration(scenarioId, platform) + } + + // Find the configuration for the resolved variant + const configuration = configurations.find(config => config.scenarioId === `${scenarioId}_${variant}`) || + configurations.find(config => config.scenarioId === scenarioId) + + if (!configuration) { + // Fallback to default configuration + return this.getDefaultConfiguration(scenarioId, platform) + } + + return this.mapConfigurationToResponse(configuration) + } + + async getDefaultConfiguration(scenarioId?: string, platform?: Platform): Promise { + // If scenario is specified, try to find default for that scenario + if (scenarioId) { + const configuration = await this.configurationRepository.findByScenarioAndPlatform( + `default_${scenarioId}`, + platform || Platform.WEB, + ) + + if (configuration) { + return this.mapConfigurationToResponse(configuration) + } + } + + // Fallback to global default for platform + const defaultConfig = await this.configurationRepository.findDefaultForPlatform( + platform || Platform.WEB, + ) + + if (defaultConfig) { + return this.mapConfigurationToResponse(defaultConfig) + } + + // Ultimate fallback - create a basic default response + return this.createFallbackDefaultResponse(scenarioId || 'default', platform || Platform.WEB) + } + + async hasConfiguration(scenarioId: string, platform: Platform): Promise { + const configuration = await this.configurationRepository.findByScenarioAndPlatform(scenarioId, platform) + return configuration !== null && configuration.isActive + } + + async getAvailableScenarios(platform: Platform): Promise { + const configurations = await this.configurationRepository.findByPlatform(platform) + const scenarioIds = configurations + .filter(config => config.isActive) + .map(config => config.scenarioId) + .filter((scenarioId, index, array) => array.indexOf(scenarioId) === index) // Remove duplicates + + return scenarioIds + } + + async isCompatible(scenarioId: string, platform: Platform, renderEngineVersion: SemanticVersion): Promise { + const configuration = await this.configurationRepository.findByScenarioAndPlatform(scenarioId, platform) + if (!configuration) { + return false + } + + return configuration.isCompatibleWith(renderEngineVersion) + } + + private mapConfigurationToResponse(configuration: Configuration): ConfigurationResponse { + return { + schemaVersion: configuration.schema.version.toString(), + renderEngine: { + minVersion: configuration.minRenderEngineVersion.toString(), + maxVersion: configuration.maxRenderEngineVersion.toString(), + }, + scenarioId: configuration.scenarioId, + platform: configuration.platform, + lastModified: configuration.updatedAt.toISOString(), + etag: this.generateETag(configuration), + config: configuration.toJSON().config as Record, + } + } + + private createFallbackDefaultResponse(scenarioId: string, platform: Platform): ConfigurationResponse { + return { + schemaVersion: '1.0.0', + renderEngine: { + minVersion: '1.0.0', + maxVersion: '99.99.99', + }, + scenarioId: 'default', + platform: platform, + lastModified: new Date().toISOString(), + etag: this.generateETagForDefault(scenarioId, platform), + config: { + type: 'Screen', + id: 'default_screen', + children: [ + { + type: 'Text', + props: { text: 'Something went wrong. Please try again.' }, + }, + ], + }, + } + } + + private generateETag(configuration: Configuration): string { + return `"${configuration.id.toPrimitive()}-${configuration.schema.version.toString()}"` + } + + private generateETagForDefault(scenarioId: string, platform: Platform): string { + return `"default-${scenarioId}-${platform}"` + } +} \ No newline at end of file diff --git a/packages/application/src/services/index.ts b/packages/application/src/services/index.ts new file mode 100644 index 0000000..9f08aaf --- /dev/null +++ b/packages/application/src/services/index.ts @@ -0,0 +1 @@ +export * from './configuration.service.js' \ No newline at end of file diff --git a/packages/application/src/use-cases/get-configuration.use-case.ts b/packages/application/src/use-cases/get-configuration.use-case.ts new file mode 100644 index 0000000..5edf03d --- /dev/null +++ b/packages/application/src/use-cases/get-configuration.use-case.ts @@ -0,0 +1,88 @@ +import { SemanticVersion } from '../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../schema-management/shared/enums/platform-support.enum.js' +import { ConfigurationService, ConfigurationRequest } from '../schema-management/configuration/index.js' + +export interface GetConfigurationRequest { + scenarioId: string + platform: Platform + renderEngineVersion: string // Will be parsed to SemanticVersion + userId: string + experimentId?: string +} + +export interface GetConfigurationResponse { + schemaVersion: string + renderEngine: { + minVersion: string + maxVersion: string + } + scenarioId: string + platform: string + lastModified: string + etag: string + config: Record +} + +export class GetConfigurationUseCase { + constructor(private readonly configurationService: ConfigurationService) {} + + async execute(request: GetConfigurationRequest): Promise { + // Parse and validate render engine version + const renderEngineVersion = this.parseSemanticVersion(request.renderEngineVersion) + + // Validate inputs + this.validateRequest(request) + + // Create domain request + const domainRequest: ConfigurationRequest = { + scenarioId: request.scenarioId, + platform: request.platform, + renderEngineVersion, + userId: request.userId, + experimentId: request.experimentId, + } + + // Execute service call + const response = await this.configurationService.getConfiguration(domainRequest) + + return this.mapToResponse(response) + } + + private parseSemanticVersion(versionString: string): SemanticVersion { + try { + return SemanticVersion.fromString(versionString) + } catch (error) { + throw new Error(`Invalid semantic version format: ${versionString}`) + } + } + + private validateRequest(request: GetConfigurationRequest): void { + if (!request.scenarioId || request.scenarioId.trim().length === 0) { + throw new Error('scenarioId is required') + } + + if (!request.platform || !Object.values(Platform).includes(request.platform)) { + throw new Error('Invalid platform') + } + + if (!request.renderEngineVersion || request.renderEngineVersion.trim().length === 0) { + throw new Error('renderEngineVersion is required') + } + + if (!request.userId || request.userId.trim().length === 0) { + throw new Error('userId is required') + } + } + + private mapToResponse(domainResponse: any): GetConfigurationResponse { + return { + schemaVersion: domainResponse.schemaVersion, + renderEngine: domainResponse.renderEngine, + scenarioId: domainResponse.scenarioId, + platform: domainResponse.platform, + lastModified: domainResponse.lastModified, + etag: domainResponse.etag, + config: domainResponse.config, + } + } +} \ No newline at end of file diff --git a/packages/application/src/use-cases/get-default-configuration.use-case.ts b/packages/application/src/use-cases/get-default-configuration.use-case.ts new file mode 100644 index 0000000..ea52d38 --- /dev/null +++ b/packages/application/src/use-cases/get-default-configuration.use-case.ts @@ -0,0 +1,48 @@ +import { Platform } from '../schema-management/shared/enums/platform-support.enum.js' +import { ConfigurationService } from '../schema-management/configuration/index.js' + +export interface GetDefaultConfigurationRequest { + scenarioId?: string + platform?: Platform +} + +export interface GetDefaultConfigurationResponse { + schemaVersion: string + scenarioId: string + config: Record +} + +export class GetDefaultConfigurationUseCase { + constructor(private readonly configurationService: ConfigurationService) {} + + async execute(request: GetDefaultConfigurationRequest = {}): Promise { + // Validate inputs + this.validateRequest(request) + + // Execute service call + const response = await this.configurationService.getDefaultConfiguration( + request.scenarioId, + request.platform, + ) + + return this.mapToResponse(response) + } + + private validateRequest(request: GetDefaultConfigurationRequest): void { + if (request.scenarioId && request.scenarioId.trim().length === 0) { + throw new Error('scenarioId cannot be empty string') + } + + if (request.platform && !Object.values(Platform).includes(request.platform)) { + throw new Error('Invalid platform') + } + } + + private mapToResponse(domainResponse: any): GetDefaultConfigurationResponse { + return { + schemaVersion: domainResponse.schemaVersion, + scenarioId: domainResponse.scenarioId, + config: domainResponse.config, + } + } +} \ No newline at end of file diff --git a/packages/application/src/use-cases/index.ts b/packages/application/src/use-cases/index.ts new file mode 100644 index 0000000..b41599a --- /dev/null +++ b/packages/application/src/use-cases/index.ts @@ -0,0 +1,2 @@ +export * from './get-configuration.use-case.js' +export * from './get-default-configuration.use-case.js' \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/entities/configuration.entity.ts b/packages/domain/src/schema-management/configuration/entities/configuration.entity.ts new file mode 100644 index 0000000..487a7c3 --- /dev/null +++ b/packages/domain/src/schema-management/configuration/entities/configuration.entity.ts @@ -0,0 +1,113 @@ +import { Entity, EntityData } from '../../../kernel/entities/base.entity.js' +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { Name, Description, SemanticVersion } from '../../../kernel/value-objects/index.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' +import { Schema } from '../../schema-definition/entities/schema.entity.js' + +interface ConfigurationData extends EntityData { + scenarioId: string + name: Name + description: Description + platform: Platform + minRenderEngineVersion: SemanticVersion + maxRenderEngineVersion: SemanticVersion + schema: Schema + isActive: boolean +} + +export class Configuration extends Entity { + private constructor(props: ConfigurationData) { + super(props) + } + + public static create( + props: Omit & { id?: ID; createdAt?: Date; updatedAt?: Date }, + ): Configuration { + const data: ConfigurationData = { + ...props, + id: props.id ?? ID.generate(), + createdAt: props.createdAt ?? new Date(), + updatedAt: props.updatedAt ?? new Date(), + } + + return new Configuration(data) + } + + get scenarioId(): string { + return this.data.scenarioId + } + + get name(): Name { + return this.data.name + } + + get description(): Description { + return this.data.description + } + + get platform(): Platform { + return this.data.platform + } + + get minRenderEngineVersion(): SemanticVersion { + return this.data.minRenderEngineVersion + } + + get maxRenderEngineVersion(): SemanticVersion { + return this.data.maxRenderEngineVersion + } + + get schema(): Schema { + return this.data.schema + } + + get isActive(): boolean { + return this.data.isActive + } + + public isCompatibleWith(renderEngineVersion: SemanticVersion): boolean { + const minVersion = this.minRenderEngineVersion + const maxVersion = this.maxRenderEngineVersion + + // Check if render engine version is within the supported range + const isGreaterThanOrEqualMin = renderEngineVersion.compareTo(minVersion) >= 0 + const isLessThanOrEqualMax = renderEngineVersion.compareTo(maxVersion) <= 0 + + return isGreaterThanOrEqualMin && isLessThanOrEqualMax + } + + public deactivate(): void { + this.data.isActive = false + this.data.updatedAt = new Date() + } + + public activate(): void { + this.data.isActive = true + this.data.updatedAt = new Date() + } + + public updateSchema(newSchema: Schema): void { + this.data.schema = newSchema + this.data.updatedAt = new Date() + } + + public updateRenderEngineVersionRange(minVersion: SemanticVersion, maxVersion: SemanticVersion): void { + this.data.minRenderEngineVersion = minVersion + this.data.maxRenderEngineVersion = maxVersion + this.data.updatedAt = new Date() + } + + public toJSON(): Record { + const json = super.toJSON() + + return { + ...json, + scenario_id: this.scenarioId, + platform: this.platform, + min_render_engine_version: this.minRenderEngineVersion.toString(), + max_render_engine_version: this.maxRenderEngineVersion.toString(), + schema_version: this.schema.version.toString(), + config: this.schema.toJSON(), + } + } +} \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/entities/index.ts b/packages/domain/src/schema-management/configuration/entities/index.ts new file mode 100644 index 0000000..d794a6a --- /dev/null +++ b/packages/domain/src/schema-management/configuration/entities/index.ts @@ -0,0 +1 @@ +export * from './configuration.entity.js' \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/index.ts b/packages/domain/src/schema-management/configuration/index.ts new file mode 100644 index 0000000..2f094d4 --- /dev/null +++ b/packages/domain/src/schema-management/configuration/index.ts @@ -0,0 +1,3 @@ +export * from './entities/index.js' +export * from './repositories/index.js' +export * from './services/index.js' \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/repositories/configuration.repository.interface.ts b/packages/domain/src/schema-management/configuration/repositories/configuration.repository.interface.ts new file mode 100644 index 0000000..7dbf54a --- /dev/null +++ b/packages/domain/src/schema-management/configuration/repositories/configuration.repository.interface.ts @@ -0,0 +1,55 @@ +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' +import { Configuration } from '../entities/configuration.entity.js' + +export interface ConfigurationRepository { + /** + * Find configuration by scenario ID and platform + */ + findByScenarioAndPlatform(scenarioId: string, platform: Platform): Promise + + /** + * Find all configurations for a scenario + */ + findByScenario(scenarioId: string): Promise + + /** + * Find configurations compatible with a specific render engine version + */ + findCompatibleConfigurations( + scenarioId: string, + platform: Platform, + renderEngineVersion: SemanticVersion, + ): Promise + + /** + * Find active configuration by scenario and platform + */ + findActiveConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Find configuration by ID + */ + findById(id: ID): Promise + + /** + * Save configuration + */ + save(configuration: Configuration): Promise + + /** + * Delete configuration + */ + delete(id: ID): Promise + + /** + * Find all configurations for a platform + */ + findByPlatform(platform: Platform): Promise + + /** + * Find default configuration for a platform + */ + findDefaultForPlatform(platform: Platform): Promise +} \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/repositories/index.ts b/packages/domain/src/schema-management/configuration/repositories/index.ts new file mode 100644 index 0000000..2d8c78c --- /dev/null +++ b/packages/domain/src/schema-management/configuration/repositories/index.ts @@ -0,0 +1 @@ +export * from './configuration.repository.interface.js' \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/services/configuration.service.interface.ts b/packages/domain/src/schema-management/configuration/services/configuration.service.interface.ts new file mode 100644 index 0000000..86d8698 --- /dev/null +++ b/packages/domain/src/schema-management/configuration/services/configuration.service.interface.ts @@ -0,0 +1,50 @@ +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' + +export interface ConfigurationRequest { + scenarioId: string + platform: Platform + renderEngineVersion: SemanticVersion + userId: string + experimentId?: string +} + +export interface ConfigurationResponse { + schemaVersion: string + renderEngine: { + minVersion: string + maxVersion: string + } + scenarioId: string + platform: string + lastModified: string + etag: string + config: Record +} + +export interface ConfigurationService { + /** + * Get configuration for a scenario with experiment resolution + */ + getConfiguration(request: ConfigurationRequest): Promise + + /** + * Get default configuration for a platform + */ + getDefaultConfiguration(scenarioId?: string, platform?: Platform): Promise + + /** + * Check if configuration exists for scenario and platform + */ + hasConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Get all available scenarios for a platform + */ + getAvailableScenarios(platform: Platform): Promise + + /** + * Validate if render engine version is compatible with scenario + */ + isCompatible(scenarioId: string, platform: Platform, renderEngineVersion: SemanticVersion): Promise +} \ No newline at end of file diff --git a/packages/domain/src/schema-management/configuration/services/index.ts b/packages/domain/src/schema-management/configuration/services/index.ts new file mode 100644 index 0000000..7cb1f7f --- /dev/null +++ b/packages/domain/src/schema-management/configuration/services/index.ts @@ -0,0 +1 @@ +export * from './configuration.service.interface.js' \ No newline at end of file diff --git a/packages/domain/src/schema-management/index.ts b/packages/domain/src/schema-management/index.ts index f432e48..98c0bab 100644 --- a/packages/domain/src/schema-management/index.ts +++ b/packages/domain/src/schema-management/index.ts @@ -3,3 +3,4 @@ export * from './shared/enums/index.js' export * from './schema-definition/entities/index.js' export * from './schema-definition/value-objects/index.js' export * from './template-management/index.js' +export * from './configuration/index.js' diff --git a/packages/domain/src/schema-management/shared/enums/platform-support.enum.ts b/packages/domain/src/schema-management/shared/enums/platform-support.enum.ts index 180d6ed..38a415e 100644 --- a/packages/domain/src/schema-management/shared/enums/platform-support.enum.ts +++ b/packages/domain/src/schema-management/shared/enums/platform-support.enum.ts @@ -3,3 +3,9 @@ export enum PlatformSupport { IOS = 'ios', ANDROID = 'android', } + +export enum Platform { + IOS = 'ios', + ANDROID = 'android', + WEB = 'web', +} diff --git a/packages/infrastructure/src/index.ts b/packages/infrastructure/src/index.ts index 9c705c0..d9b8e41 100644 --- a/packages/infrastructure/src/index.ts +++ b/packages/infrastructure/src/index.ts @@ -1 +1,2 @@ export * from './kernel/index.js' +export * from './schema-management/configuration/index.js' diff --git a/packages/infrastructure/src/kernel/index.ts b/packages/infrastructure/src/kernel/index.ts index 4a7c949..a9262ab 100644 --- a/packages/infrastructure/src/kernel/index.ts +++ b/packages/infrastructure/src/kernel/index.ts @@ -4,3 +4,4 @@ export * from './shell/index.js' export * from './logging/index.js' export * from './cache/index.js' export * from './time/index.js' +export * from './value-objects/index.js' diff --git a/packages/infrastructure/src/kernel/value-objects/id.value-object.ts b/packages/infrastructure/src/kernel/value-objects/id.value-object.ts new file mode 100644 index 0000000..391ad08 --- /dev/null +++ b/packages/infrastructure/src/kernel/value-objects/id.value-object.ts @@ -0,0 +1,41 @@ +/** + * ID Value Object + * + * Represents a unique identifier for domain entities. + * Uses UUID v4 for generating unique IDs. + */ + +export class ID { + private constructor(private readonly value: string) {} + + static generate(): ID { + // Simple UUID v4 generator for this example + // In production, use a proper UUID library + const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = Math.random() * 16 | 0 + const v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) + return new ID(uuid) + } + + static create(id: string): ID { + return new ID(id) + } + + toPrimitive(): string { + return this.value + } + + toString(): string { + return this.value + } + + equals(other: ID): boolean { + return this.value === other.value + } + + toJSON(): string { + return this.value + } +} \ No newline at end of file diff --git a/packages/infrastructure/src/kernel/value-objects/index.ts b/packages/infrastructure/src/kernel/value-objects/index.ts new file mode 100644 index 0000000..c2268a1 --- /dev/null +++ b/packages/infrastructure/src/kernel/value-objects/index.ts @@ -0,0 +1,2 @@ +export * from './id.value-object.js' +export * from './semantic-version.value-object.js' \ No newline at end of file diff --git a/packages/infrastructure/src/kernel/value-objects/semantic-version.value-object.ts b/packages/infrastructure/src/kernel/value-objects/semantic-version.value-object.ts new file mode 100644 index 0000000..21092c9 --- /dev/null +++ b/packages/infrastructure/src/kernel/value-objects/semantic-version.value-object.ts @@ -0,0 +1,193 @@ +/** + * SemanticVersion Value Object + * + * Semantic version following the semver specification (major.minor.patch). + * Provides type-safe version management for domain objects with comparison and increment operations. + */ + +export class SemanticVersion { + private constructor( + private readonly majorVersion: number, + private readonly minorVersion: number, + private readonly patchVersion: number, + ) {} + + /** + * Create a new SemanticVersion instance + * @param major Major version number (breaking changes) + * @param minor Minor version number (new features, backward compatible) + * @param patch Patch version number (bug fixes, backward compatible) + * @throws Error if any version number is invalid + * @returns New SemanticVersion instance + */ + static create(major: number, minor: number, patch: number): SemanticVersion { + // Business Rule: All version numbers must be non-negative integers + if (!Number.isInteger(major) || major < 0) { + throw new Error(`Major version must be a non-negative integer, got ${major}`) + } + if (!Number.isInteger(minor) || minor < 0) { + throw new Error(`Minor version must be a non-negative integer, got ${minor}`) + } + if (!Number.isInteger(patch) || patch < 0) { + throw new Error(`Patch version must be a non-negative integer, got ${patch}`) + } + + return new SemanticVersion(major, minor, patch) + } + + /** + * Create a SemanticVersion from a version string + * @param versionString Version string in format "major.minor.patch" + * @throws Error if version numbers are invalid or format is wrong + * @returns New SemanticVersion instance + */ + static fromString(versionString: string): SemanticVersion { + // Business Rule: Must be in format "major.minor.patch" + const versionRegex = /^(\d+)\.(\d+)\.(\d+)$/ + const match = versionString.match(versionRegex) + + if (!match) { + throw new Error(`Invalid version format: ${versionString}. Expected format: major.minor.patch`) + } + + const major = Number.parseInt(match[1], 10) + const minor = Number.parseInt(match[2], 10) + const patch = Number.parseInt(match[3], 10) + + return SemanticVersion.create(major, minor, patch) + } + + /** + * Create initial version 0.0.1 + * @returns New SemanticVersion instance with version 0.0.1 + */ + static initial(): SemanticVersion { + return SemanticVersion.create(0, 0, 1) + } + + /** + * Get major version number + */ + get major(): number { + return this.majorVersion + } + + /** + * Get minor version number + */ + get minor(): number { + return this.minorVersion + } + + /** + * Get patch version number + */ + get patch(): number { + return this.patchVersion + } + + /** + * Increment major version and reset minor and patch to 0 + * @returns New SemanticVersion instance with incremented major version + */ + incrementMajor(): SemanticVersion { + return SemanticVersion.create(this.majorVersion + 1, 0, 0) + } + + /** + * Increment minor version and reset patch to 0 + * @returns New SemanticVersion instance with incremented minor version + */ + incrementMinor(): SemanticVersion { + return SemanticVersion.create(this.majorVersion, this.minorVersion + 1, 0) + } + + /** + * Increment patch version + * @returns New SemanticVersion instance with incremented patch version + */ + incrementPatch(): SemanticVersion { + return SemanticVersion.create(this.majorVersion, this.minorVersion, this.patchVersion + 1) + } + + /** + * Compare this version with another version + * @param other Version to compare against + * @returns -1 if this < other, 0 if equal, 1 if this > other + */ + compareTo(other: SemanticVersion): -1 | 0 | 1 { + // Business Rule: Versions compared by major first, then minor, then patch + if (this.majorVersion !== other.majorVersion) { + return this.majorVersion < other.majorVersion ? -1 : 1 + } + + if (this.minorVersion !== other.minorVersion) { + return this.minorVersion < other.minorVersion ? -1 : 1 + } + + if (this.patchVersion !== other.patchVersion) { + return this.patchVersion < other.patchVersion ? -1 : 1 + } + + return 0 + } + + /** + * Check if this version is greater than another version + * @param other Version to compare against + * @returns true if this version is greater than other + */ + isGreaterThan(other: SemanticVersion): boolean { + return this.compareTo(other) === 1 + } + + /** + * Check if this version is less than another version + * @param other Version to compare against + * @returns true if this version is less than other + */ + isLessThan(other: SemanticVersion): boolean { + return this.compareTo(other) === -1 + } + + /** + * Check if this version is compatible with another version + * @param other Version to check compatibility against + * @returns true if versions have the same major version (compatible) + */ + isCompatibleWith(other: SemanticVersion): boolean { + // Business Rule: Same major version indicates compatibility + return this.majorVersion === other.majorVersion + } + + /** + * Check if this version equals another version + * @param other Version to compare against + * @returns true if versions are equal + */ + equals(other: SemanticVersion): boolean { + return this.majorVersion === other.majorVersion && + this.minorVersion === other.minorVersion && + this.patchVersion === other.patchVersion + } + + /** + * Convert version to string representation + * @returns Version string in "major.minor.patch" format + */ + toString(): string { + return `${this.majorVersion}.${this.minorVersion}.${this.patchVersion}` + } + + /** + * Convert version to JSON-serializable object + * @returns Object with major, minor, and patch properties + */ + toJSON(): { major: number; minor: number; patch: number } { + return { + major: this.majorVersion, + minor: this.minorVersion, + patch: this.patchVersion, + } + } +} \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/entities/configuration.entity.ts b/packages/infrastructure/src/schema-management/configuration/entities/configuration.entity.ts new file mode 100644 index 0000000..d62a29f --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/entities/configuration.entity.ts @@ -0,0 +1,115 @@ +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' + +interface ConfigurationData { + id: ID + scenarioId: string + platform: Platform + minRenderEngineVersion: SemanticVersion + maxRenderEngineVersion: SemanticVersion + schema: Record + isActive: boolean + createdAt: Date + updatedAt: Date +} + +export class Configuration { + private constructor(private readonly data: ConfigurationData) {} + + public static create( + props: Omit & { id?: ID; createdAt?: Date; updatedAt?: Date }, + ): Configuration { + const data: ConfigurationData = { + ...props, + id: props.id ?? ID.generate(), + createdAt: props.createdAt ?? new Date(), + updatedAt: props.updatedAt ?? new Date(), + } + + return new Configuration(data) + } + + get id(): ID { + return this.data.id + } + + get scenarioId(): string { + return this.data.scenarioId + } + + get platform(): Platform { + return this.data.platform + } + + get minRenderEngineVersion(): SemanticVersion { + return this.data.minRenderEngineVersion + } + + get maxRenderEngineVersion(): SemanticVersion { + return this.data.maxRenderEngineVersion + } + + get schema(): Record { + return this.data.schema + } + + get isActive(): boolean { + return this.data.isActive + } + + get createdAt(): Date { + return this.data.createdAt + } + + get updatedAt(): Date { + return this.data.updatedAt + } + + public isCompatibleWith(renderEngineVersion: SemanticVersion): boolean { + const minVersion = this.minRenderEngineVersion + const maxVersion = this.maxRenderEngineVersion + + // Check if render engine version is within the supported range + const isGreaterThanOrEqualMin = renderEngineVersion.compareTo(minVersion) >= 0 + const isLessThanOrEqualMax = renderEngineVersion.compareTo(maxVersion) <= 0 + + return isGreaterThanOrEqualMin && isLessThanOrEqualMax + } + + public deactivate(): void { + this.data.isActive = false + this.data.updatedAt = new Date() + } + + public activate(): void { + this.data.isActive = true + this.data.updatedAt = new Date() + } + + public updateSchema(newSchema: Record): void { + this.data.schema = newSchema + this.data.updatedAt = new Date() + } + + public updateRenderEngineVersionRange(minVersion: SemanticVersion, maxVersion: SemanticVersion): void { + this.data.minRenderEngineVersion = minVersion + this.data.maxRenderEngineVersion = maxVersion + this.data.updatedAt = new Date() + } + + public toJSON(): Record { + return { + id: this.data.id.toString(), + scenario_id: this.scenarioId, + platform: this.platform, + min_render_engine_version: this.minRenderEngineVersion.toString(), + max_render_engine_version: this.maxRenderEngineVersion.toString(), + schema_version: '1.0.0', // TODO: Extract from schema when schema entity is available + config: this.schema, + is_active: this.isActive, + created_at: this.createdAt.toISOString(), + updated_at: this.updatedAt.toISOString(), + } + } +} \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/entities/index.ts b/packages/infrastructure/src/schema-management/configuration/entities/index.ts new file mode 100644 index 0000000..d794a6a --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/entities/index.ts @@ -0,0 +1 @@ +export * from './configuration.entity.js' \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/index.ts b/packages/infrastructure/src/schema-management/configuration/index.ts new file mode 100644 index 0000000..2f094d4 --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/index.ts @@ -0,0 +1,3 @@ +export * from './entities/index.js' +export * from './repositories/index.js' +export * from './services/index.js' \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.interface.ts b/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.interface.ts new file mode 100644 index 0000000..7dbf54a --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.interface.ts @@ -0,0 +1,55 @@ +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' +import { Configuration } from '../entities/configuration.entity.js' + +export interface ConfigurationRepository { + /** + * Find configuration by scenario ID and platform + */ + findByScenarioAndPlatform(scenarioId: string, platform: Platform): Promise + + /** + * Find all configurations for a scenario + */ + findByScenario(scenarioId: string): Promise + + /** + * Find configurations compatible with a specific render engine version + */ + findCompatibleConfigurations( + scenarioId: string, + platform: Platform, + renderEngineVersion: SemanticVersion, + ): Promise + + /** + * Find active configuration by scenario and platform + */ + findActiveConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Find configuration by ID + */ + findById(id: ID): Promise + + /** + * Save configuration + */ + save(configuration: Configuration): Promise + + /** + * Delete configuration + */ + delete(id: ID): Promise + + /** + * Find all configurations for a platform + */ + findByPlatform(platform: Platform): Promise + + /** + * Find default configuration for a platform + */ + findDefaultForPlatform(platform: Platform): Promise +} \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.ts b/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.ts new file mode 100644 index 0000000..926b7e3 --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/repositories/configuration.repository.ts @@ -0,0 +1,56 @@ +import { ID } from '../../../kernel/value-objects/id.value-object.js' +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../../schema-management/shared/enums/platform-support.enum.js' +import { Configuration } from '../../../schema-management/configuration/entities/configuration.entity.js' +import { ConfigurationRepository } from '../../../schema-management/configuration/repositories/configuration.repository.interface.js' + +// TODO: This would be implemented with actual database schema +// For now, this is a placeholder implementation +export class ConfigurationRepositoryImpl implements ConfigurationRepository { + async findByScenarioAndPlatform(scenarioId: string, platform: Platform): Promise { + // TODO: Implement actual database query + return null + } + + async findByScenario(scenarioId: string): Promise { + // TODO: Implement actual database query + return [] + } + + async findCompatibleConfigurations( + scenarioId: string, + platform: Platform, + renderEngineVersion: SemanticVersion, + ): Promise { + // TODO: Implement actual database query with version compatibility check + return [] + } + + async findActiveConfiguration(scenarioId: string, platform: Platform): Promise { + // TODO: Implement actual database query + return null + } + + async findById(id: ID): Promise { + // TODO: Implement actual database query + return null + } + + async save(configuration: Configuration): Promise { + // TODO: Implement actual database save + } + + async delete(id: ID): Promise { + // TODO: Implement actual database delete + } + + async findByPlatform(platform: Platform): Promise { + // TODO: Implement actual database query + return [] + } + + async findDefaultForPlatform(platform: Platform): Promise { + // TODO: Implement actual database query for default configuration + return null + } +} \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/repositories/index.ts b/packages/infrastructure/src/schema-management/configuration/repositories/index.ts new file mode 100644 index 0000000..2d8c78c --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/repositories/index.ts @@ -0,0 +1 @@ +export * from './configuration.repository.interface.js' \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/services/configuration.service.interface.ts b/packages/infrastructure/src/schema-management/configuration/services/configuration.service.interface.ts new file mode 100644 index 0000000..86d8698 --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/services/configuration.service.interface.ts @@ -0,0 +1,50 @@ +import { SemanticVersion } from '../../../kernel/value-objects/semantic-version.value-object.js' +import { Platform } from '../../shared/enums/platform-support.enum.js' + +export interface ConfigurationRequest { + scenarioId: string + platform: Platform + renderEngineVersion: SemanticVersion + userId: string + experimentId?: string +} + +export interface ConfigurationResponse { + schemaVersion: string + renderEngine: { + minVersion: string + maxVersion: string + } + scenarioId: string + platform: string + lastModified: string + etag: string + config: Record +} + +export interface ConfigurationService { + /** + * Get configuration for a scenario with experiment resolution + */ + getConfiguration(request: ConfigurationRequest): Promise + + /** + * Get default configuration for a platform + */ + getDefaultConfiguration(scenarioId?: string, platform?: Platform): Promise + + /** + * Check if configuration exists for scenario and platform + */ + hasConfiguration(scenarioId: string, platform: Platform): Promise + + /** + * Get all available scenarios for a platform + */ + getAvailableScenarios(platform: Platform): Promise + + /** + * Validate if render engine version is compatible with scenario + */ + isCompatible(scenarioId: string, platform: Platform, renderEngineVersion: SemanticVersion): Promise +} \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/configuration/services/index.ts b/packages/infrastructure/src/schema-management/configuration/services/index.ts new file mode 100644 index 0000000..7cb1f7f --- /dev/null +++ b/packages/infrastructure/src/schema-management/configuration/services/index.ts @@ -0,0 +1 @@ +export * from './configuration.service.interface.js' \ No newline at end of file diff --git a/packages/infrastructure/src/schema-management/shared/enums/component-type.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/component-type.enum.ts new file mode 100644 index 0000000..902cee9 --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/component-type.enum.ts @@ -0,0 +1,66 @@ +export enum ComponentType { + // Basic UI Components + BUTTON = 'button', + TEXT = 'text', + IMAGE = 'image', + ICON = 'icon', + + // Input Components + TEXT_INPUT = 'textInput', + NUMBER_INPUT = 'numberInput', + EMAIL_INPUT = 'emailInput', + PASSWORD_INPUT = 'passwordInput', + TEXTAREA = 'textarea', + SELECT = 'select', + CHECKBOX = 'checkbox', + RADIO = 'radio', + SWITCH = 'switch', + SLIDER = 'slider', + + // Layout Components + CONTAINER = 'container', + ROW = 'row', + COLUMN = 'column', + GRID = 'grid', + STACK = 'stack', + CARD = 'card', + + // Navigation Components + NAVBAR = 'navbar', + TAB_BAR = 'tabBar', + SIDEBAR = 'sidebar', + BREADCRUMB = 'breadcrumb', + + // Data Display Components + LIST = 'list', + TABLE = 'table', + TREE = 'tree', + ACCORDION = 'accordion', + CAROUSEL = 'carousel', + + // Feedback Components + ALERT = 'alert', + TOAST = 'toast', + MODAL = 'modal', + POPOVER = 'popover', + TOOLTIP = 'tooltip', + PROGRESS = 'progress', + SPINNER = 'spinner', + + // Form Components + FORM = 'form', + FORM_FIELD = 'formField', + FORM_GROUP = 'formGroup', + + // Advanced Components + CHART = 'chart', + MAP = 'map', + CALENDAR = 'calendar', + DATE_PICKER = 'datePicker', + TIME_PICKER = 'timePicker', + COLOR_PICKER = 'colorPicker', + FILE_UPLOAD = 'fileUpload', + + // Custom Components + CUSTOM = 'custom', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/data-type-category.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/data-type-category.enum.ts new file mode 100644 index 0000000..d1986df --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/data-type-category.enum.ts @@ -0,0 +1,44 @@ +export enum DataTypeCategory { + // Primitive Types + STRING = 'string', + NUMBER = 'number', + BOOLEAN = 'boolean', + INTEGER = 'integer', + FLOAT = 'float', + DATE = 'date', + TIME = 'time', + DATETIME = 'datetime', + + // Complex Types + ARRAY = 'array', + OBJECT = 'object', + MAP = 'map', + SET = 'set', + + // Special Types + ANY = 'any', + UNKNOWN = 'unknown', + VOID = 'void', + NULL = 'null', + UNDEFINED = 'undefined', + + // Union Types + UNION = 'union', + INTERSECTION = 'intersection', + + // Function Types + FUNCTION = 'function', + ASYNC_FUNCTION = 'asyncFunction', + + // Custom Types + ENUM = 'enum', + CUSTOM = 'custom', + + // Platform-specific Types + COLOR = 'color', + DIMENSION = 'dimension', + FONT = 'font', + IMAGE_URL = 'imageUrl', + COMPONENT_REF = 'componentRef', + TEMPLATE_REF = 'templateRef', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/index.ts b/packages/infrastructure/src/schema-management/shared/enums/index.ts new file mode 100644 index 0000000..bfe26ef --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/index.ts @@ -0,0 +1,7 @@ +export * from './component-type.enum.js' +export * from './data-type-category.enum.js' +export * from './validation-rule-type.enum.js' +export * from './validation-severity.enum.js' +export * from './platform-support.enum.js' +export * from './template-status.enum.js' +export * from './schema-status.enum.js' diff --git a/packages/infrastructure/src/schema-management/shared/enums/platform-support.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/platform-support.enum.ts new file mode 100644 index 0000000..38a415e --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/platform-support.enum.ts @@ -0,0 +1,11 @@ +export enum PlatformSupport { + WEB = 'web', + IOS = 'ios', + ANDROID = 'android', +} + +export enum Platform { + IOS = 'ios', + ANDROID = 'android', + WEB = 'web', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/schema-status.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/schema-status.enum.ts new file mode 100644 index 0000000..18cf58f --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/schema-status.enum.ts @@ -0,0 +1,5 @@ +export enum SchemaStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + DEPRECATED = 'deprecated', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/template-status.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/template-status.enum.ts new file mode 100644 index 0000000..3fa2732 --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/template-status.enum.ts @@ -0,0 +1,5 @@ +export enum TemplateStatus { + DRAFT = 'draft', + PUBLISHED = 'published', + DEPRECATED = 'deprecated', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/validation-rule-type.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/validation-rule-type.enum.ts new file mode 100644 index 0000000..91a56a6 --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/validation-rule-type.enum.ts @@ -0,0 +1,69 @@ +export enum SchemaValidationRuleType { + // Basic Validation + REQUIRED = 'required', + TYPE = 'type', + MIN = 'min', + MAX = 'max', + MIN_LENGTH = 'minLength', + MAX_LENGTH = 'maxLength', + PATTERN = 'pattern', + ENUM = 'enum', + + // String Validation + EMAIL = 'email', + URL = 'url', + UUID = 'uuid', + PHONE = 'phone', + ZIP_CODE = 'zipCode', + + // Number Validation + RANGE = 'range', + POSITIVE = 'positive', + NEGATIVE = 'negative', + INTEGER = 'integer', + NUMBER = 'number', + FLOAT = 'float', + MULTIPLE_OF = 'multipleOf', + + // Date/Time Validation + MIN_DATE = 'minDate', + MAX_DATE = 'maxDate', + FUTURE_DATE = 'futureDate', + PAST_DATE = 'pastDate', + + // Array Validation + MIN_ITEMS = 'minItems', + MAX_ITEMS = 'maxItems', + UNIQUE_ITEMS = 'uniqueItems', + CONTAINS = 'contains', + + // Object Validation + MIN_PROPERTIES = 'minProperties', + MAX_PROPERTIES = 'maxProperties', + REQUIRED_PROPERTIES = 'requiredProperties', + DEPENDENT_PROPERTIES = 'dependentProperties', + + // Format Validation + FORMAT = 'format', + CUSTOM_FORMAT = 'customFormat', + + // Business Logic Validation + BUSINESS_RULE = 'businessRule', + CUSTOM = 'custom', + ASYNC = 'async', + + // Reference Validation + REFERENCE = 'reference', + EXISTS = 'exists', + UNIQUE = 'unique', + + // Conditional Validation + CONDITIONAL = 'conditional', + IF_THEN_ELSE = 'ifThenElse', + + // Composite Validation + ALL_OF = 'allOf', + ANY_OF = 'anyOf', + ONE_OF = 'oneOf', + NOT = 'not', +} diff --git a/packages/infrastructure/src/schema-management/shared/enums/validation-severity.enum.ts b/packages/infrastructure/src/schema-management/shared/enums/validation-severity.enum.ts new file mode 100644 index 0000000..fe48500 --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/enums/validation-severity.enum.ts @@ -0,0 +1,5 @@ +export enum SchemaValidationSeverity { + ERROR = 'error', + WARNING = 'warning', + INFO = 'info', +} diff --git a/packages/infrastructure/src/schema-management/shared/index.ts b/packages/infrastructure/src/schema-management/shared/index.ts new file mode 100644 index 0000000..70a3f1e --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/index.ts @@ -0,0 +1,2 @@ +export * from './enums/index.js' +export * from './types/index.js' diff --git a/packages/infrastructure/src/schema-management/shared/types/index.ts b/packages/infrastructure/src/schema-management/shared/types/index.ts new file mode 100644 index 0000000..d889f39 --- /dev/null +++ b/packages/infrastructure/src/schema-management/shared/types/index.ts @@ -0,0 +1,2 @@ +// Schema types moved to domain package +// This file is kept for compatibility but exports nothing