Source

src/common/BaseService.js

/* eslint-env node */

/**
 * @module Core Infrastructure
 * @fileoverview BaseService - Business Logic Layer with Mixin Composition
 *
 * This is the heart of the Service-Oriented Architecture. BaseService provides:
 * - Consistent CRUD API across all domain services
 * - Automatic cross-cutting concerns via mixins (observability, caching, etc.)
 * - Transaction support
 * - Entity/POJO normalization
 * - Graph (relation) query support
 *
 * **Architecture - The 3-Layer Pattern:**
 * ```
 * HTTP Layer (API routes, schemas)
 *      ↓
 * Service Layer (business logic) ← YOU ARE HERE
 *      ↓
 * Repository Layer (data access)
 *      ↓
 * Database (PostgreSQL)
 * ```
 *
 * **Mixin Composition Pattern:**
 * BaseService is actually `RawBaseService` wrapped with mixins. Each mixin adds
 * functionality without modifying the base class:
 *
 * ```javascript
 * // This file exports:
 * const BaseService = withObservable(RawBaseService);
 *
 * // In production, the full composition would be:
 * const BaseService =
 *   withObservable(      // Automatic timing, metrics, logging
 *   withCacheable(       // Read-through cache with entity rehydration
 *   withPluggable(       // Event-driven plugins
 *   withAccessControl(   // RBAC enforcement
 *   withSoftDeletable(   // Soft delete support
 *   withActivatable(     // Activation state management
 *     RawBaseService     // Core CRUD operations
 *   ))))));
 * ```
 *
 * **Why Mixins?**
 * - **Composable**: Pick which concerns each service needs
 * - **Testable**: Test each concern independently
 * - **No Fragile Base Class**: Avoid deep inheritance hierarchies
 * - **Opt-In**: Services choose their mixins
 *
 * **Usage Pattern:**
 * ```javascript
 * class IconService extends BaseService {
 *   constructor(opts = {}) {
 *     super({
 *       repository: new IconRepository(),
 *       entityClass: IconEntity,
 *       ...opts
 *     });
 *   }
 *
 *   // Custom business logic
 *   async publishIcon(iconId, opts = {}) {
 *     const icon = await this.getById(iconId, opts);
 *     if (!icon) throw new Error('Icon not found');
 *
 *     // Validate business rules
 *     if (!icon.svgPath) throw new Error('Icon has no SVG');
 *
 *     // Update with automatic observability, caching, events
 *     return this.update(iconId, { isActive: true }, opts);
 *   }
 * }
 * ```
 *
 * **What You Get Automatically:**
 * - ✅ Timing/logging for all operations (via withObservable)
 * - ✅ Metrics collection (operation duration, success/failure)
 * - ✅ Event emission for monitoring
 * - ✅ Consistent error handling
 * - ✅ Transaction support
 *
 * @see {@link BaseRepository} For data access layer
 * @see {@link BaseEntity} For entity layer
 */

const { withObservable } = require('./mixins/service');

/**
 * Raw base service class before mixin wrapping.
 *
 * Provides core CRUD operations and delegates to repository layer. This class
 * is wrapped by mixins before export (see bottom of file).
 *
 * **Key Responsibilities:**
 * - Normalize inputs (Entity → POJO) before passing to repository
 * - Provide consistent CRUD interface
 * - Handle graph queries (eager loading)
 * - Support pagination (offset-based, cursor pending)
 * - Transaction passthrough
 *
 * **Does NOT Handle** (delegated to mixins/repositories):
 * - Caching (use withCacheable mixin)
 * - Observability (use withObservable mixin)
 * - Access control (use withAccessControl mixin)
 * - Event emission (use withPluggable mixin)
 * - Soft deletes (use withSoftDeletable mixin)
 *
 * @class RawBaseService
 * @private
 */
class RawBaseService  {
    /**
     * Create a service instance.
     * @param {Object} options
     * @param {Object} options.repository
     * @param {Function} options.entityClass
     */
    constructor({ repository, entityClass }) {
        if (!repository || !entityClass) {
            throw new Error('BaseService requires both a repository and an entityClass');
        }
        this.repository = repository;
        this.entityClass = entityClass;
    }

    /**
     * Determine whether a value looks like one of our Entity instances.
     * Uses presence of toJSON, hiddenFields marker, or class name suffix.
     * @param {*} val
     * @returns {boolean}
     */
    isEntity(val) {
        if (!val || typeof val !== 'object') {
            return false;
        }
        const hasToJSON = typeof val.toJSON === 'function';
        const hasHiddenFields = Array.isArray(val.hiddenFields);
        const nameLooksLikeEntity = typeof val.constructor?.name === 'string'
            ? val.constructor.name.endsWith('Entity')
            : false;
        return hasToJSON || hasHiddenFields || nameLooksLikeEntity;
    }

    /**
     * Get the default graph name for expanded queries.
     * Override in subclasses to provide a different default.
     * @returns {string} Default graph name.
     */
    getDefaultGraphName() {
        return 'default';
    }

    /**
     * Get the graph expression for a named graph.
     * Override in subclasses to provide named graphs.
     * @param {string} name - Name of the graph.
     * @returns {string} Graph expression.
     */
    getGraphExpression(name) {
        return '';
    }

    /**
     * Get the graph modifiers for a named graph.
     * Override in subclasses to provide named graph modifiers.
     * @param {string} name - Name of the graph.
     * @returns {Object<string,Function>|undefined} Graph modifiers.
     */
    getGraphModifiers(name) {
        return undefined;
    }

    /**
     * Get a single record matching a where clause, including related data.
     * Returns an Entity instance or null.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {string} [opts.graphName] - Name of the graph to use.
     * @param {boolean} [opts.includeHiddenFields=false] - Whether to include hidden fields.
     * @param {Object} [opts.trx] - Knex transaction.
     * @returns {Promise<*>} Entity instance or null
     */
    async getOneExpanded(where = {}, { graphName = this.getDefaultGraphName(), includeHiddenFields = false, trx } = {}) {
        const graph = this.getGraphExpression(graphName);
        const modifiers = this.getGraphModifiers(graphName);
        return this.repository.findOneWithRelations(where, graph, {
            entityClass: this.entityClass,
            entityOptions: { includeHiddenFields },
            trx,
            modifiers,
        });
    }

    /**
     * Get records matching a where clause, including related data.
     * Returns an array of Entity instances.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {string} [opts.graphName] - Name of the graph to use.
     * @param {boolean} [opts.includeHiddenFields=false] - Whether to include hidden fields.
     * @param {Object} [opts.trx] - Knex transaction.
     * @returns {Promise<Array<*>>} Array of Entity instances
     */
    async getWhereExpanded(where = {}, { graphName = this.getDefaultGraphName(), includeHiddenFields = false, trx } = {}) {
        const graph = this.getGraphExpression(graphName);
        const modifiers = this.getGraphModifiers(graphName);
        return this.repository.withRelations(where, graph, {
            entityClass: this.entityClass,
            entityOptions: { includeHiddenFields },
            trx,
            modifiers,
        });
    }

    /**
     * Convert an Entity to a POJO using toJSON, otherwise return the value unchanged.
     * Dates inside entity.toJSON() are expected to be serialized consistently.
     * @param {*} val
     * @returns {*}
     */
    toPlain(val) {
        return this.isEntity(val) ? val.toJSON() : val;
    }

    /**
     * Get a single record matching a where clause.
     * Returns an Entity instance or null.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    // async getOne(where = {}, { trx } = {}) {
    //     return await this.repository.findOne(where, { entityClass: this.entityClass, trx });
    // }
    async getOne(where = {}, { trx, includeHiddenFields = false } = {}) {
        return this.repository.findOne(where, {
            entityClass    : this.entityClass,
            entityOptions  : { includeHiddenFields },
            trx
        });
    }


    /**
     * Get a single record matching a where clause, including related data.
     * Returns an Entity instance or null.
     * @param {Object} where
     * @param {string} graph
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     * @returns {Promise<*>} Entity instance or null
     * @example
     *   const user = await userService.getOneWithRelations(
     *     { id: 1 },
     *     'account, roles',
     *     { trx }
     *   );
     *   console.log(user.account, user.roles);
     */
    async getOneWithRelations(where = {}, graph = '', { trx, modifiers, includeHiddenFields = false } = {}) {
        const result = await this.repository.findOneWithRelations(where, graph, {
            entityClass    : this.entityClass,
            entityOptions  : { includeHiddenFields },
            trx,
            modifiers
        });
        return result;
    }

    /**
     * Get records matching a where clause, including related data.
     * Returns an array of Entity instances.
     * @param {Object} where
     * @param {string} graph
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     * @returns {Promise<Array<*>>} Array of Entity instances
     * @example
     *   const users = await userService.getWhereWithRelations(
     *     { isActive: true },
     *     'account, roles',
     *     { trx }
     *   );
     *   console.log(users[0].account, users[0].roles);
     */
    async getWhereWithRelations(where = {}, graph = '', { trx, modifiers, includeHiddenFields = false } = {}) {
        return this.repository.withRelations(where, graph, {
            entityClass    : this.entityClass,
            entityOptions  : { includeHiddenFields },
            trx,
            modifiers
        });
    }

    /**
     * Paginate records using the repository's offset/limit paging.
     * Returns { results, total, page, pageSize, totalPages } with results as Entities.
     * @param {Object} where
     * @param {number} page          1-based page number
     * @param {number} pageSize
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    // async paginate(where = {}, page = 1, pageSize = 10, { trx } = {}) {
    //     return await this.repository.paginate(where, page, pageSize, {
    //         entityClass: this.entityClass,
    //         trx,
    //     });
    // }
    async paginate(where = {}, page = 1, pageSize = 10, { trx, includeHiddenFields = false } = {}) {
        return this.repository.paginate(where, page, pageSize, {
            entityClass    : this.entityClass,
            entityOptions  : { includeHiddenFields },
            trx
        });
    }

    /**
     * Get all records.
     * Returns an array of Entity instances.
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async getAll({ trx } = {}) {
        return await this.repository.findAll({}, { entityClass: this.entityClass, trx });
    }

    /**
     * !IMPORTANT: This method returns a maximum of 1000 records as a sanity check. 
     * If you need more, use paginate function instead.
     * Get records matching a where clause.
     * Returns an array of Entity instances.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    // async getWhere(where = {}, { trx } = {}) {
    //     const { results } = await this.paginate(where, 1, 1000, { trx });
    //     return results;
    // }
    async getWhere(where = {}, { trx, includeHiddenFields = false } = {}) {
        const { results } = await this.paginate(where, 1, 1000, { trx, includeHiddenFields });
        return results;
    }
 
    /**
     * Get a record by its ID.
     * Returns an Entity instance or null.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    // async getById(id, { trx } = {}) {
    //     return await this.repository.findById(id, { entityClass: this.entityClass, trx });
    // }
    async getById(id, { trx, includeHiddenFields = false } = {}) {
        return this.repository.findById(id, {
            entityClass    : this.entityClass,
            entityOptions  : { includeHiddenFields },
            trx
        });
    }

    /**
     * Create a record.
     * Accepts either a POJO or an Entity instance.
     * @param {Object} data
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async create(data, { trx } = {}) {
        return await this.repository.create(this.toPlain(data), { trx });
    }

    /**
     * Update a record by its ID.
     * Accepts either a POJO or an Entity instance for the update data.
     * @param {string|number} id
     * @param {Object} data
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async update(id, data, { trx } = {}) {
        return await this.repository.update(id, this.toPlain(data), { trx });
    }

    /**
     * Delete a record by its ID.
     * Returns the number of rows deleted.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async delete(id, { trx } = {}) {
        return await this.repository.delete(id, { trx });
    }

    /**
     * Soft delete a record by its ID.
     * Marks the record inactive and deleted.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    // TODO: softDelete is implemented via mixin now, so this is commented out to avoid confusion.
    // async softDelete(id, { trx } = {}) {
    //     return await this.repository.update(id, { is_active: false, is_deleted: true }, { trx });
    // }

    /**
     * Upsert a record based on a where clause.
     * Accepts either a POJO or an Entity instance.
     * @param {Object} data
     * @param {Object} [whereClause={}]
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async upsert(data, whereClause = {}, { trx } = {}) {
        return await this.repository.upsert(this.toPlain(data), whereClause, { trx });
    }

    /**
     * Check if a record exists for a where clause.
     * Returns true or false.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async exists(where = {}, { trx } = {}) {
        return this.repository.exists(where, { trx });
    }

    /**
     * Assert that at least one record exists for a where clause.
     * Throws an error if not found.
     * @param {Object} where
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async assertExists(where = {}, { trx } = {}) {
        const found = await this.getOne(where, { trx });
        if (!found) {
            throw new Error(`${this.entityClass.name} not found`, JSON.stringify(where, null, 2));
        }
        return found;
    }

    /**
     * Get active records, optionally filtered by additional WHERE criteria.
     * Returns an array of Entity instances.
     * @param {Object} [where={}] - Additional WHERE clause to filter results
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async getActive(where = {}, { trx } = {}) {
        const fullWhere = { ...where, is_active: true };

        // Only add is_deleted filter if the model has that column
        const modelSchema = this.repository.model.jsonSchema;
        if (modelSchema && modelSchema.properties && modelSchema.properties.is_deleted) {
            fullWhere.is_deleted = false;
        }

        return this.repository.findAll(
            fullWhere,
            { entityClass: this.entityClass, trx }
        );
    }

    /**
     * Activate a record by its ID.
     * Throws if not found.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async activate(id, { trx } = {}) {
        const record = await this.getById(id, { trx });
        if (!record) {
            throw new Error(`${this.entityClass.name} with ID ${id} not found`);
        }

        const updateData = { is_active: true };

        // Only set is_deleted if the model has that column
        const modelSchema = this.repository.model.jsonSchema;
        if (modelSchema && modelSchema.properties && modelSchema.properties.is_deleted) {
            updateData.is_deleted = false;
        }

        return this.repository.update(id, updateData, { trx });
    }

    /**
     * Deactivate a record by its ID.
     * Throws if not found.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async deactivate(id, { trx } = {}) {
        const record = await this.getById(id, { trx });
        if (!record) {
            throw new Error(`${this.entityClass.name} with ID ${id} not found`);
        }

        const updateData = { is_active: false };

        // Only set is_deleted if the model has that column
        const modelSchema = this.repository.model.jsonSchema;
        if (modelSchema && modelSchema.properties && modelSchema.properties.is_deleted) {
            updateData.is_deleted = false;
        }

        return this.repository.update(id, updateData, { trx });
    }

    /**
     * Toggle the active status of a record by its ID.
     * Throws if not found.
     * @param {string|number} id
     * @param {Object} [opts]
     * @param {Object} [opts.trx]
     */
    async toggleActive(id, { trx } = {}) {
        const record = await this.repository.findById(id, { entityClass: this.entityClass, trx });
        if (! record) {
            throw new Error(`${this.entityClass.name} with id ${id} not found`);
        }
        return this.repository.update(id, { is_active: !record.isActive }, { trx });
    }

    /**
     * Cursor-based pagination passthrough.
     * Defers to repository.cursorPage where the actual implementation will live.
     * Returns { results, nextCursor, prevCursor } when implemented in repository.
     * @param {Object} where
     * @param {Array} order
     * @param {Object} opts
     * @param {number} [opts.limit=20]
     * @param {string|null} [opts.cursor=null]
     * @param {Object} [opts.trx]
     */
    async cursorPage(where = {}, order = [], { limit = 20, cursor = null, trx } = {}) {
        return this.repository.cursorPage(where, order, { limit, cursor, trx });
    }
}

/**
 * Export BaseService with observability mixin applied.
 *
 * This demonstrates the mixin composition pattern. RawBaseService provides
 * core CRUD operations, while withObservable wraps it to add automatic:
 * - Timing (operation duration tracking)
 * - Logging (structured logs for all operations)
 * - Metrics (success/failure counts, duration stats)
 * - Event emission (observability.service events)
 *
 * **How Mixins Work:**
 * withObservable() is a higher-order function that:
 * 1. Takes a service class (RawBaseService)
 * 2. Returns a new class that extends it
 * 3. Wraps key methods (getById, create, update, etc.)
 * 4. Adds pre/post hooks for timing and logging
 * 5. Emits events for monitoring
 *
 * **What This Means for Your Code:**
 * ```javascript
 * const iconService = new IconService();
 * const icon = await iconService.getById(123);
 *
 * // Automatically logged:
 * // "icon-service.getById success 45ms { id: 123 }"
 *
 * // Automatically emitted:
 * // Event: observability.service
 * //   { service: 'icon', operation: 'getById', phase: 'success', durationMs: 45 }
 *
 * // Automatically tracked:
 * // Metric: operation_duration_ms=45 operation=getById service=icon result=success
 * ```
 *
 * **In Production:**
 * The full composition would include more mixins:
 * ```javascript
 * const BaseService =
 *   withObservable(
 *   withCacheable(
 *   withPluggable(
 *   withAccessControl(
 *   withSoftDeletable(
 *   withActivatable(
 *     RawBaseService
 *   ))))));
 * ```
 *
 * @type {Class}
 * @see {@link withObservable} For observability mixin documentation
 */
const BaseService = withObservable(RawBaseService);

module.exports = BaseService;