Source

src/common/BaseEntity.js

/* eslint-env node */

/**
 * @module Core Infrastructure
 * @fileoverview BaseEntity class and factory for creating immutable entity instances from Objection.js models.
 *
 * This module provides the foundation for the Entity layer in our Service-Oriented Architecture.
 * Entities are immutable, validated data transfer objects that represent domain models.
 *
 * **Key Features:**
 * - Automatic camelCase conversion from snake_case database columns
 * - Hidden field filtering (e.g., passwords, tokens)
 * - Allowed column whitelisting for precise control
 * - Related entity materialization (nested objects/arrays)
 * - JSON schema derivation from Objection.js models
 * - Immutable by default (frozen instances)
 *
 * **Usage Pattern:**
 * ```javascript
 * // Define entity from Objection model
 * const IconEntity = createEntityFromModel(IconModel, {
 *   // Extra methods
 *   isPublished() { return this.isActive && !this.isDeleted; }
 * }, {
 *   hiddenFields: ['internalId', 'secretKey'],
 *   relatedEntities: {
 *     set: () => SetEntity,
 *     images: () => ImageEntity
 *   }
 * });
 *
 * // Use in service layer
 * const icon = new IconEntity(dbRow);
 * console.log(icon.svgPath); // camelCase
 * console.log(icon.secretKey); // undefined (hidden)
 * Object.isFrozen(icon); // true
 * ```
 *
 * @see {@link https://vincit.github.io/objection.js/ Objection.js Documentation}
 */

const Inflector = require('inflected');

/**
 * Base class for all domain entities.
 *
 * Provides core functionality for data transfer objects including serialization,
 * cloning, and hidden field management. All entities in the system extend this class
 * either directly or via createEntityFromModel factory.
 *
 * **Design Philosophy:**
 * - Entities are immutable value objects
 * - Entities should not contain business logic (use Services)
 * - Entities are serializable (toJSON) for API responses
 * - Entities filter sensitive data automatically
 *
 * @class
 * @example
 * // Direct usage (rare, usually use createEntityFromModel)
 * class UserEntity extends BaseEntity {
 *   static hiddenFields = ['password', 'resetToken'];
 * }
 *
 * const user = new UserEntity({ id: 1, email: 'user@example.com', password: 'secret' });
 * console.log(user.password); // undefined (filtered by hiddenFields)
 */
class BaseEntity {

    hiddenFields = [];

    /**
     * Construct entity instance from arbitrary data, automatically filtering hidden fields.
     *
     * Hidden fields (e.g., passwords, tokens) are removed unless explicitly requested
     * via entityOptions.includeHiddenFields = true. This ensures sensitive data is not
     * accidentally exposed in API responses.
     *
     * @param {Object} data - Raw data object (typically from database or API)
     * @param {Object} [entityOptions={}] - Entity construction options
     * @param {boolean} [entityOptions.includeHiddenFields=false] - If true, retain hidden fields
     *
     * @example
     * // Without hidden fields (default)
     * const user = new UserEntity({ id: 1, email: 'user@example.com', password: 'secret' });
     * console.log(user.password); // undefined
     *
     * @example
     * // With hidden fields (for internal operations)
     * const user = new UserEntity(
     *   { id: 1, email: 'user@example.com', password: 'secret' },
     *   { includeHiddenFields: true }
     * );
     * console.log(user.password); // 'secret'
     */
    constructor(data = {}, entityOptions = {}) {
        if (!data || typeof data !== 'object') {
            return;
        }

        const shouldIncludeHidden = entityOptions.includeHiddenFields === true;
        const hidden = this.constructor.hiddenFields || [];

        // Only filter hidden fields if includeHiddenFields is false
        if (!shouldIncludeHidden) {
            for (const field of hidden) {
                if (field in data) {
                    delete data[field];
                }
            }
        }

        Object.assign(this, data);
    }

    /**
     * Create a new entity instance by cloning current data and applying updates.
     *
     * Since entities are immutable (frozen), this method provides a way to create
     * a modified version without mutating the original. Useful for applying partial
     * updates while preserving immutability.
     *
     * @param {Object} updates - Fields to add/override in the cloned instance
     * @returns {this} New entity instance with updates applied
     *
     * @example
     * const icon1 = new IconEntity({ id: 1, name: 'home', isActive: true });
     * const icon2 = icon1.cloneWith({ name: 'home-alt' });
     *
     * console.log(icon1.name); // 'home' (unchanged)
     * console.log(icon2.name); // 'home-alt'
     * console.log(icon2.id);   // 1 (copied from original)
     * console.log(icon1 !== icon2); // true (different instances)
     */
    cloneWith(updates = {}) {
        return new this.constructor({ ...this, ...updates });
    }

    /**
     * Serialize entity to plain JSON object, recursively converting nested entities.
     *
     * This method is automatically called by JSON.stringify() and ensures all nested
     * entities (arrays or single objects) are also serialized properly. Essential for
     * API responses where entities need to be converted to plain JSON.
     *
     * @returns {Object} Plain JavaScript object suitable for JSON serialization
     *
     * @example
     * const icon = new IconEntity({
     *   id: 1,
     *   name: 'home',
     *   set: new SetEntity({ id: 10, name: 'Material' }),
     *   images: [
     *     new ImageEntity({ id: 100, url: '/img1.png' }),
     *     new ImageEntity({ id: 101, url: '/img2.png' })
     *   ]
     * });
     *
     * const json = icon.toJSON();
     * // All nested entities converted to plain objects
     * console.log(json.set.name); // 'Material'
     * console.log(json.images[0].url); // '/img1.png'
     *
     * @example
     * // Automatic usage with JSON.stringify
     * const icon = new IconEntity({ id: 1, name: 'home' });
     * const str = JSON.stringify(icon);
     * console.log(str); // '{"id":1,"name":"home"}'
     */
    toJSON() {
        const json = {};
        for (const key of Object.getOwnPropertyNames(this)) {
            if (key === 'hiddenFields' || key === 'constructor') {
                continue;
            }

            const val = this[key];

            if (Array.isArray(val)) {
                json[key] = val.map(item =>
                    typeof item?.toJSON === 'function' ? item.toJSON() : item
                );
            }
            else if (typeof val?.toJSON === 'function') {
                json[key] = val.toJSON();
            }
            else {
                json[key] = val;
            }
        }
        return json;
    }
}

/**
 * Factory function to create an immutable Entity class from an Objection.js Model.
 *
 * This is the primary way to define entities in the system. It automatically:
 * - Converts snake_case database columns to camelCase entity properties
 * - Derives a JSON schema from the Model's jsonSchema
 * - Filters hidden fields (passwords, tokens, etc.)
 * - Materializes related entities (lazy-loaded to avoid circular dependencies)
 * - Freezes instances for immutability
 * - Validates data against the derived schema
 *
 * **Why This Pattern?**
 * - **Separation of Concerns**: Database models (Objection) vs. domain entities (business logic)
 * - **Immutability**: Frozen entities prevent accidental mutations
 * - **Type Safety**: JSON schema provides runtime validation
 * - **Security**: Hidden fields prevent sensitive data leakage
 * - **Flexibility**: Related entities can be included/excluded dynamically
 *
 * @param {Object} ModelClass - Objection.js Model class with jsonSchema
 * @param {Object} [extraMethods={}] - Additional methods to add to entity instances
 * @param {Object} [options={}] - Entity configuration options
 * @param {Array<string>} [options.hiddenFields=[]] - Fields to hide (e.g., ['password', 'resetToken'])
 * @param {Array<string>} [options.allowedColumns=[]] - If provided, only these fields are included (whitelist)
 * @param {Object<string,Function>} [options.relatedEntities={}] - Related entity loaders (lazy functions)
 * @param {boolean} [options.freeze=true] - Whether to freeze instances (default: true)
 *
 * @returns {Function} Entity class extending BaseEntity with derived schema
 *
 * @example
 * // Basic entity
 * const IconEntity = createEntityFromModel(IconModel, {}, {
 *   hiddenFields: ['internalId']
 * });
 *
 * const icon = new IconEntity({ id: 1, name: 'home', internal_id: 'secret' });
 * console.log(icon.name); // 'home' (camelCase)
 * console.log(icon.internalId); // undefined (hidden)
 * Object.isFrozen(icon); // true
 *
 * @example
 * // Entity with related entities (lazy-loaded to avoid circular deps)
 * const IconEntity = createEntityFromModel(IconModel, {
 *   // Extra methods
 *   isPublished() {
 *     return this.isActive && !this.isDeleted;
 *   }
 * }, {
 *   hiddenFields: ['secretKey'],
 *   relatedEntities: {
 *     set: () => require('./SetEntity'),      // Lazy load
 *     images: () => require('./ImageEntity')
 *   }
 * });
 *
 * const iconData = {
 *   id: 1,
 *   name: 'home',
 *   set: { id: 10, name: 'Material' },
 *   images: [{ id: 100, url: '/img.png' }]
 * };
 *
 * const icon = new IconEntity(iconData);
 * console.log(icon.set instanceof SetEntity); // true
 * console.log(icon.images[0] instanceof ImageEntity); // true
 * console.log(icon.isPublished()); // true (custom method)
 *
 * @example
 * // Entity with allowedColumns (whitelist)
 * const PublicIconEntity = createEntityFromModel(IconModel, {}, {
 *   allowedColumns: ['id', 'name', 'svgPath', 'isActive']
 *   // Only these fields will be included, all others dropped
 * });
 */
const createEntityFromModel = (
    ModelClass,
    extraMethods = {},
    { hiddenFields = [], allowedColumns = [], relatedEntities = {}, freeze = true } = {}
) => {
    const toCamel = (s) => Inflector.camelize(s, false);

    const modelProps = ModelClass?.jsonSchema?.properties || {};
    const modelRequired = Array.isArray(ModelClass?.jsonSchema?.required)
        ? ModelClass.jsonSchema.required
        : [];

    // Debug helpers (retain if useful during build-time generation)
    console.log('ModelClass.jsonSchema', ModelClass.jsonSchema?.properties);
    if (ModelClass?.getColumnNames) {
        console.log('ModelClass columns:', ModelClass.getColumnNames().join(', '));
    }

    const hiddenSet  = new Set((hiddenFields || []).map(toCamel));
    const allowedSet = new Set((allowedColumns || []).map(toCamel)); // empty => allow all (minus hidden)

    console.log('Entity hidden fields:', Array.from(hiddenSet).join(', '));
    console.log('Entity allowed columns:', allowedSet.size > 0 ? Array.from(allowedSet).join(', ') : '(all)');

    const mapProp = (key, def) => {
        const type = def?.type;
        const out = {};

        if (type === 'string') {
            out.type = 'string';
            const lower = key.toLowerCase();
            if (lower.endsWith('at') || lower.endsWith('_at') || lower.endsWith('date') || lower.endsWith('_date')) {
                out.format = 'date-time';
            }
            if (def?.maxLength) {
                out.maxLength = def.maxLength;
            }
            if (Array.isArray(def?.enum)) {
                out.enum = def.enum.slice();
            }
            return out;
        }

        if (type === 'integer') {
            out.type = 'integer';
            return out;
        }

        if (type === 'number') {
            out.type = 'number';
            return out;
        }

        if (type === 'boolean') {
            out.type = 'boolean';
            return out;
        }

        if (type === 'array') {
            out.type = 'array';
            if (def?.items) {
                out.items = typeof def.items === 'object' ? { ...def.items } : {};
            }
            return out;
        }

        if (type === 'object') {
            out.type = 'object';
            if (def?.properties) {
                out.properties = Object.fromEntries(
                    Object.entries(def.properties).map(([propKey, propDef]) => [toCamel(propKey), mapProp(propKey, propDef)])
                );
            }
            if (Array.isArray(def?.required)) {
                out.required = def.required.map(toCamel);
            }
            return out;
        }

        return {};
    };

    const entityProperties = Object.fromEntries(
        Object.entries(modelProps)
            .map(([snakeKey, def]) => [toCamel(snakeKey), mapProp(snakeKey, def)])
            .filter(([camelKey]) => {
                if (hiddenSet.has(camelKey)) return false;
                if (allowedSet.size > 0 && !allowedSet.has(camelKey)) return false;
                return true;
            })
    );

    const entityRequired = modelRequired
        .map(toCamel)
        .filter((key) => !hiddenSet.has(key))
        .filter((key) => allowedSet.size === 0 || allowedSet.has(key));

    const derivedJsonSchema = {
        type: 'object',
        properties: entityProperties,
    };
    if (entityRequired.length > 0) {
        derivedJsonSchema.required = entityRequired;
    }

    class ModelEntity extends BaseEntity {
        static relatedEntities = relatedEntities;
        static hiddenFields = hiddenFields;
        static allowedColumns = allowedColumns;

        static _relatedEntityCache = new Map();

        static _getRelatedEntityClass(key) {
            const loader = this.relatedEntities?.[key];
            if (!loader) return null;

            if (this._relatedEntityCache.has(key)) {
                return this._relatedEntityCache.get(key);
            }

            if (typeof loader !== 'function') {
                throw new Error(`relatedEntities.${key} must be a function returning the Entity class`);
            }

            const EntityClass = loader();
            if (!EntityClass) {
                throw new Error(`relatedEntities.${key}() returned a falsy value`);
            }

            this._relatedEntityCache.set(key, EntityClass);
            return EntityClass;
        }

        constructor(data = {}, entityOptions = {}) {
            const schemaProps = ModelClass.jsonSchema?.properties || {};

            const schemaCamel = Object.keys(schemaProps).reduce((acc, key) => {
                acc[toCamel(key)] = true;
                return acc;
            }, {});

            const dataCamel = mapKeys(data, (key) => toCamel(key));

            const baseFields = {};
            const relationCandidates = {};

            for (const [key, val] of Object.entries(dataCamel)) {
                if (schemaCamel[key]) {
                    baseFields[key] = val;
                } else {
                    relationCandidates[key] = val;
                }
            }

            // Apply hidden + allowed filtering before assigning base fields
            // Unless includeHiddenFields is true, then skip hidden field filtering
            const filteredBaseFields = {};
            const shouldIncludeHidden = entityOptions.includeHiddenFields === true;

            for (const [key, val] of Object.entries(baseFields)) {
                if (!shouldIncludeHidden && hiddenSet.has(key)) continue;
                if (allowedSet.size > 0 && !allowedSet.has(key)) continue;
                filteredBaseFields[key] = val;
            }

            super(filteredBaseFields, entityOptions);

            for (const [key, val] of Object.entries(relationCandidates)) {
                const Related = this.constructor._getRelatedEntityClass(key);
                if (!Related) continue;

                this[key] = Array.isArray(val)
                    ? val.map((entry) => new Related(entry))
                    : new Related(val);
            }

            // Freeze instances by default (opt-out via options.freeze = false)
            if (freeze) {
                Object.freeze(this);
            }
        }

        static getJsonSchema() {
            return derivedJsonSchema;
        }
    }

    Object.defineProperty(ModelEntity, 'jsonSchema', {
        value: derivedJsonSchema,
        enumerable: true,
        configurable: false,
        writable: false,
    });

    Object.assign(ModelEntity.prototype, extraMethods);

    return ModelEntity;
};

/**
 * Map an object's keys using a transform function.
 * @param {Object} obj
 * @param {function(string):string} fn
 * @returns {Object}
 */
const mapKeys = (obj, fn) => {
    if (!obj || typeof obj !== 'object') {
        return {};
    }
    const out = {};
    for (const [key, val] of Object.entries(obj)) {
        out[fn(key)] = val;
    }
    return out;
}

module.exports = {
    BaseEntity,
    createEntityFromModel,
};