Source

common/dataRecord.js

kiss.data.RecordFactory = function (modelId) {
    /**
     * To see how a **Record** relates to models, fields and collections, please refer to the [Model documentation](kiss.data.Model.html).
     * 
     * A Record can't be instanciated directly.
     * You have to use the Model's **create** method:
     * ```
     * let myUser = userModel.create({
     *      firstName: "Bob",
     *      lastName: "Wilson"
     * })
     * ```
     * A record automatically has default methods for CRUD operations:
     * - save
     * - read
     * - update
     * - delete
     * 
     * @class
     * @param {object} [recordData] - Optional data used to create the record
     * @param {boolean} [inherit] - If true, create a blank record then assign recordData to it
     * @returns {object} Record
     * 
     * @example
     * // Get the "user" model
     * const userModel = kiss.app.models.user
     * 
     * // Create a new user instance
     * const myUser = userModel.create({
     *  firstName: "Bob",
     *  lastName: "Wilson",
     *  email: "bob.wilson@gmail.com"
     * })
     * 
     * // Save the new record
     * await myUser.save()
     * 
     * // Call custom model's method
     * myUser.sendEmail({
     *  subject: "Hello ${myContact.firstName}",
     *  message: "How are you?"
     * })
     * 
     * // Update the record
     * await myUser.update({
     *  firstName: "Bobby"
     * })
     * 
     * // Delete the record
     * await myUser.delete()
     * 
     */
    const Record = class {

        constructor(recordData, inherit) {
            this.model = kiss.app.models[modelId]
            this.db = this.model.db

            if (!recordData || inherit) {
                this.id = uid()
                this.createdAt = new Date().toISOString()
                this.createdBy = kiss.session.getUserId()

                this._initDefaultValues()
                this._computeFields()
            } else {
                this.id = recordData.id || uid()
                Object.assign(this, recordData)
            }

            if (inherit) Object.assign(this, recordData)

            return this
        }

        /**
         * Set or restore the model's default values
         * 
         * Default values can be predefined values like:
         * - username
         * - today
         * - now
         * - unid
         * 
         * Or:
         * - today+10
         * - today-5
         * 
         * Or: 
         * - {YYYY} // Year
         * - {MM} // Month
         * - {DD} // Day
         * - {hh} // Hour
         * - {mm} // Minutes
         * - {ss} // Seconds
         * - {XX} // Random letters
         * - {NN} // Random numbers
         * 
         * @private
         * @ignore
         * @returns this
         */
        _initDefaultValues() {
            this.model.getFields().forEach(field => {
                let defaultValue = field.value

                if (defaultValue === 0) {
                    this[field.id] = defaultValue
                    return
                }

                if (defaultValue && typeof defaultValue == "string") {
                    if ((defaultValue.includes("today+") || defaultValue.includes("today-"))) {
                        // Process special date formatting like:
                        // today+10, today-5
                        const daysFromNow = Number(field.value.split("today")[1])
                        if (!isNaN(daysFromNow)) {
                            let newDate = (new Date()).addDays(daysFromNow)
                            defaultValue = newDate.toISO()
                        }
                    }
                    else {
                        // Process special values like:
                        defaultValue = defaultValue
                            .replace("username", kiss.session.getUserId())
                            .replace("today", new Date().toISO())
                            .replace("now", kiss.tools.getTime())
                            .replace("unid", kiss.tools.shortUid().toUpperCase())
                            .replace("{YYYY}", new Date().getFullYear())
                            .replace("{MM}", (new Date().getMonth() + 1).toString().padStart(2, "0"))
                            .replace("{DD}", (new Date().getDate()).toString().padStart(2, "0"))
                            .replace("{hh}", (new Date().getHours()).toString().padStart(2, "0"))
                            .replace("{mm}", (new Date().getMinutes()).toString().padStart(2, "0"))
                            .replace("{ss}", (new Date().getSeconds()).toString().padStart(2, "0"))
                            .replace("{XX}", String.fromCharCode(65 + Math.random() * 26 | 0) + String.fromCharCode(65 + Math.random() * 26 | 0))
                            .replace("{NN}", (Math.floor(Math.random() * 100) + "").padStart(2, "0"))
                    }

                    this[field.id] = defaultValue

                } else if (defaultValue) {

                    this[field.id] = defaultValue

                }
            })
            return this
        }

        /**
         * Check the permission (client-side) to perform an action on the record.
         * 
         * @param {string} action - "update" | "delete"
         * @returns {boolean} true if the permission is granted
         */
        async checkPermission(action) {
            const hasPermission = await kiss.acl.check({
                action,
                record: this
            })

            if (!hasPermission) {
                createNotification(txtTitleCase("#not authorized"))
                return false
            }

            return true
        }

        /**
         * Check if the record has changed since its last state
         * 
         * @param {object} [data] - Optional data to compare
         * @returns {boolean}
         */
        hasChanged(data) {
            if (!data) data = this.getSanitizedData()

            const currentState = JSON.stringify(data)
            if (currentState == this.lastState) return false

            this.lastState = currentState
            return true
        }

        /**
         * Get the record's sanitized data to keep only the model's fields
         * 
         * @returns {object} The sanitized data
         */
        getSanitizedData() {
            const data = {
                id: this.id
            }

            // this.model.getFields().forEach(field => {
            //     data[field.id] = this[field.id]
            // })

            // Include revision fields
            // const revisionFields = ["createdAt", "createdBy", "updatedAt", "updatedBy", "deletedAt", "deletedBy"]
            // revisionFields.forEach(fieldId => {
            //     data[fieldId] = this[fieldId]
            // })

            this.model.fields.forEach(field => {
                data[field.id] = this[field.id]
            })

            return data
        }

        /**
         * Save a record in the database
         * 
         * @async
         * @returns {boolean} true if successfuly created, false otherwise
         * 
         * @example
         * let newUser = userModel.create({firstName: "Bob", lastName: "Wilson"})
         * await newUser.save() // Insert the record into the database
         * newUser.lastName = "SMITH" // Update a property
         * await newUser.update() // Update the existing record according to the new data
         * await newUser.update({lastName: "JONES"}) // Explicit update of the lastName (same as above)
         */
        async save() {
            let loadingId

            try {
                log("kiss.data.Record - Saving " + this.id)
                const data = this.getSanitizedData()

                // Check permission to create
                const permission = await this.checkPermission("create")
                if (!permission) return false

                // Update db, wherever it is: in memory, offline or online
                loadingId = kiss.loadingSpinner.show()
                const response = await this.db.insertOne(this.model.id, data)
                kiss.loadingSpinner.hide(loadingId)

                if (response.error) {
                    log("kiss.data.Record - save - Error: " + response.error, 4)
                    return false
                }
                return true

            } catch (err) {
                log("kiss.data.Record - save - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
            }
        }

        /**
         * Duplicate a record in the database.
         * 
         * The copy of the record can handle its connected records in 3 ways:
         * - Duplicate the linked records and link them to the new record
         * - Link the linked records to the new record without duplication
         * - Do nothing with the linked records
         * 
         * This is a per field configuration, using the `linksToDuplicate` and `linksToMaintain` options.
         * Link fields that belong to no category will be ignored.
         * 
         * This duplicate method is very useful in some practical uses cases, like:
         * - Duplicating an order and its order details
         * - Maintaining the customer linked to the duplicated order
         * 
         * @async
         * @param {object} [config]
         * @param {boolean} [config.resetPluginFields] - If true (default), reset all the fields belonging to a plugin
         * @param {string[]} [config.linksToDuplicate] - List of link field ids which foreign records should be duplicated. Default is []
         * @param {string[]} [config.linksToMaintain] - List of link field ids which foreign records should be linked to the duplicated record. Default is []
         * @returns {*} The new record id, or false in case of error
         * 
         * @example
         * await myRecord.duplicate() // Duplicate the record
         * 
         * await myRecord.duplicate({
         *  linksToDuplicate: ["order_details"], // Duplicate linked records, and link them to the new record
         *  linksToMaintain: ["customer"], // Only link the linked records to the new record
         * })
         */        
        async duplicate(config = {}) {
            const linksToDuplicate = config.linksToDuplicate || []
            const linksToMaintain = config.linksToMaintain || []
            const resetPluginFields = (config.resetPluginFields != undefined) ? config.resetPluginFields : true

            let loadingId

            try {
                // Check permission to create
                const permission = await this.checkPermission("create")
                if (!permission) return false

                loadingId = kiss.loadingSpinner.show()

                // Duplicate the record's data, except the id, attached files and revision fields
                const data = this.getSanitizedData()
                data.id = kiss.tools.uid()
                data.createdAt = new Date().toISOString()
                data.createdBy = kiss.session.getUserId()

                // Reset attached fields
                const attachmentFields = this.model.getFieldsByType("attachment")
                attachmentFields.forEach(field => {
                    data[field.id] = []
                })

                // Reset plugin fields
                if (resetPluginFields) {
                    const pluginFields = this.model.fields.filter(field => field.isFromPlugin)
                    pluginFields.forEach(field => {
                        delete data[field.id]
                    })
                }

                const response = await this.db.insertOne(this.model.id, data)

                if (response.error) {
                    log("kiss.data.Record - duplicate - Error: " + response.error, 4)
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                let newChildren = []
                let newLinks = []

                // Manage the linked records
                let linkFields = this.model.getFieldsByType("link").filter(field => !field.deleted)

                for (let linkField of linkFields) {
                    const foreignLinkFieldId = linkField.link.fieldId
                    const foreignModelId = linkField.link.modelId

                    if (linksToDuplicate.includes(linkField.id)) {

                        // Case 1: The linked records should be duplicated and linked to the new record
                        const foreignRecords = await this.getLinkedRecordsFrom(linkField.id)
    
                        for (let foreignRecord of foreignRecords) {
    
                            // Build new children
                            foreignRecord.id = kiss.tools.uid()
                            foreignRecord.createdAt = new Date().toISOString()
                            foreignRecord.createdBy = kiss.session.getUserId()
    
                            // Reset attached fields
                            const attachmentFields = kiss.app.models[foreignModelId].getFieldsByType("attachment")
                            attachmentFields.forEach(field => {
                                foreignRecord[field.id] = []
                            })
    
                            // Reset plugin fields
                            if (resetPluginFields) {
                                const pluginFields = kiss.app.models[foreignModelId].fields.filter(field => field.isFromPlugin)
                                pluginFields.forEach(field => {
                                    delete foreignRecord[field.id]
                                })
                            }
    
                            newChildren.push(foreignRecord)
    
                            // Build new link
                            const linkInfos = {
                                id: kiss.tools.uid(),
                                mX: this.model.id,
                                rX: data.id,
                                fX: linkField.id,
                                mY: foreignModelId,
                                rY: foreignRecord.id,
                                fY: foreignLinkFieldId,
                                accountId: kiss.session.currentAccountId,
                                createdAt: new Date().toISOString(),
                                createdBy: kiss.session.getUserId()
                            }
                            newLinks.push(linkInfos)
                        }
                    }
                    else if (linksToMaintain.includes(linkField.id)) {

                        // Case 2: The linked records should be linked to the new record without duplication
                        const foreignRecords = await this.getLinkedRecordsFrom(linkField.id)

                        for (let foreignRecord of foreignRecords) {
                            const linkInfos = {
                                id: kiss.tools.uid(),
                                mX: this.model.id,
                                rX: data.id,
                                fX: linkField.id,
                                mY: foreignModelId,
                                rY: foreignRecord.id,
                                fY: foreignLinkFieldId,
                                accountId: kiss.session.currentAccountId,
                                createdAt: new Date().toISOString(),
                                createdBy: kiss.session.getUserId()
                            }
                            newLinks.push(linkInfos)
                        }
                    }

                    // Insert the new children and links
                    const foreignCollection = kiss.app.collections[foreignModelId]
                    if (newChildren.length > 0) {
                        log.info("kiss.data.Record - duplicate - Inserting new children")
                        await foreignCollection.insertMany(newChildren)
                    }
                }

                const linkCollection = kiss.app.collections.link
                if (newLinks.length > 0) {
                    log.info("kiss.data.Record - duplicate - Inserting new links")
                    await linkCollection.insertMany(newLinks)
                }

                kiss.loadingSpinner.hide(loadingId)
                return data.id

            } catch (err) {
                log("kiss.data.Record - duplicate - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
            }
        }

        /**
         * Get all the records linked to the current record from a specific field
         * 
         * @param {string} fieldId 
         * @returns {object[]} The linked records
         */
        async getLinkedRecordsFrom(fieldId) {
            return await kiss.data.relations.getLinkedRecordsFrom(this.model.id, this.id, fieldId)
        }    

        /**
         * Get the record's data from the database and update the record's instance.
         * It guaranties to get the last version of the record locally in case it was updated remotely.
         * 
         * @async
         * @returns this
         * 
         * @example
         * console.log(user) // Bob Wilson
         * await user.read()
         * console.log(user) // Bob WILSON JR
         */
        async read() {
            let record = await this.db.findOne(this.model.id, this.id)
            Object.assign(this, record)
            return this
        }

        /**
         * Update the record in the database
         * TODO: apply data validation
         * 
         * @async
         * @param {object} [update] - Optional update. If not specified, updates all the fields.
         * @param {boolean} [silent] - Set to true to hide the loading spinner (update in the background)
         * @returns {boolean} true if updated successfuly
         * 
         * @example
         * await myTask.update({status: "done"})
         * 
         * // Will work too but not optimal because it will save the whole record
         * myTask.status = "done"
         * await myTask.update() 
         */
        async update(update, silent) {
            let loadingId

            try {
                log("kiss.data.Record - update " + this.id, 0, update)
                if (!silent) loadingId = kiss.loadingSpinner.show()

                // Exit if no changes
                if (!this.hasChanged(update)) {
                    log("kiss.data.Record - update - Record didn't change, exit!")
                    if (!silent) kiss.loadingSpinner.hide(loadingId)
                    return true
                }

                const permission = await this.checkPermission("update")
                if (!permission) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                if (!update) update = this.getSanitizedData()

                Object.assign(this, update)

                const response = await this.db.updateOne(this.model.id, this.id, update)
                if (!silent) kiss.loadingSpinner.hide(loadingId)
                return response

            } catch (err) {
                log("kiss.data.Record - update - Error:", 4, err)
                if (!silent) kiss.loadingSpinner.hide(loadingId)
                return false
            }
        }

        /**
         * Update multiple fields
         * 
         * This update propagates other mutations inside the same record and also in foreign records
         * 
         * @async
         * @param {string} fieldId
         * @param {*} value
         * @param {object} transaction - The global Transaction object that contains all the database mutations to perform at once
         * @returns {boolean} true if the field was updated successfuly
         * 
         * @example
         * await user.updateDeep({
         *  fistName: "Bob",
         *  lastName: "Wilson"
         * })
         */
        async updateDeep(update) {
            let loadingId

            try {
                log(`kiss.data.Record - updateDeep ${this.id} / ${update}`)
                loadingId = kiss.loadingSpinner.show()

                const permission = await this.checkPermission("update")
                if (!permission) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                // Update the field and propagate the change
                const response = await this.db.updateOneDeep(this.model.id, this.id, update)

                kiss.loadingSpinner.hide(loadingId)

                if (response) return true

            } catch (err) {
                log("kiss.data.Record - updateDeep - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
                return false
            }
        }

        /**
         * Update a single field of the record
         * 
         * This update propagates other mutations inside the same record and also in foreign records.
         * It also check the new field value against custom validation function, if it exists.
         * 
         * @async
         * @param {string} fieldId
         * @param {*} value
         * @returns {boolean} true if the field was updated successfuly
         * 
         * @example
         * await user.updateFieldDeep("lastName", "Wilson")
         */
        async updateFieldDeep(fieldId, value) {
            let loadingId

            try {
                log(`kiss.data.Record - updateFieldDeep ${this.id} / ${fieldId} / ${value}`)
                loadingId = kiss.loadingSpinner.show()

                const permission = await this.checkPermission("update")
                if (!permission) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                const validation = await this.checkValidationRules(fieldId, value)
                if (!validation) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                // Update the undo log
                const field = this.model.getField(fieldId)
                if (field && field.type != "attachment" && field.type != "aiImage") {
                    kiss.undoRedo.addOperation({
                        id: uid(),
                        action: "updateField",
                        createdAt: new Date(),
                        createdBy: kiss.session.getUserId(),
                        modelId: this.model.id,
                        recordId: this.id,
                        fieldId,
                        oldValue: this[fieldId],
                        newValue: value
                    })
                }

                // Update the field and propagate the change
                const response = await this.db.updateOneDeep(this.model.id, this.id, {
                    [fieldId]: value
                })
                kiss.loadingSpinner.hide(loadingId)

                if (response) return true

            } catch (err) {
                log("kiss.data.Record - updateFieldDeep - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
                return false
            }
        }

        /**
         * Check the validation rules of a field if they exist
         * 
         * @param {string} fieldId 
         * @param {*} value 
         * @returns {boolean} true if the value is valid or if there is no validation rule
         */
        async checkValidationRules(fieldId, value) {
            const field = this.model.getField(fieldId)

            if (!field) {
                // Should not happen, but just in case...
                log(`kiss.data.Record - checkValidationRules - Field ${fieldId} not found`)
                return true
            }

            if (!field.validationFunction) return true

            const result = await field.validationFunction(value)
            return !!result
        }

        /**
         * Delete the record from the database
         * 
         * @async
         * @param {boolean} [sendToTrash] - If true, keeps the original record in a "trash" collection. Default = false
         * @returns {boolean} true if deleted successfuly
         * 
         * @example
         * await myTask.delete()
         */
        async delete(sendToTrash) {
            let loadingId

            try {
                log("kiss.data.Record - delete " + this.id)
                loadingId = kiss.loadingSpinner.show()

                const permission = await this.checkPermission("delete")
                if (!permission) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                // Update the undo log
                if (this.model.id != "trash") {
                    kiss.undoRedo.addOperation({
                        id: uid(),
                        action: "deleteRecord",
                        createdAt: new Date(),
                        createdBy: kiss.session.getUserId(),
                        modelId: this.model.id,
                        recordId: this.id
                    })
                }

                const response = await this.db.deleteOne(this.model.id, this.id, sendToTrash)
                kiss.loadingSpinner.hide(loadingId)

                return response

            } catch (err) {
                log("kiss.data.Record - update - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
                return false
            }
        }

        /**
         * Get a field value from the record
         * 
         * @param {string} fieldId - The field id or label
         * @returns {*} The field value
         */
        get(fieldId) {
            const field = this.model.getField(fieldId)
            if (!field) return this[fieldId]
            return this[field.id]
        }

        /**
         * Create a new record in the join table to link the 2 records
         * 
         * @ignore
         * @param {object} foreignRecord 
         * @param {string} localLinkFieldId 
         * @param {string} foreignLinkFieldId
         */
        async linkTo(foreignRecord, localLinkFieldId, foreignLinkFieldId) {
            let loadingId

            try {
                log(`kiss.data.Record - linkTo ${this.id} / ${foreignRecord.id}`)
                loadingId = kiss.loadingSpinner.show()

                const linkModel = kiss.app.models.link
                const linkInfos = {
                    id: kiss.tools.uid(),
                    mX: this.model.id,
                    rX: this.id,
                    fX: localLinkFieldId,
                    mY: foreignRecord.model.id,
                    rY: foreignRecord.id,
                    fY: foreignLinkFieldId // Never used
                }
                const newLink = linkModel.create(linkInfos)
                await newLink.save()

                // Re-compute all fields of both records with the new link
                await this.db.updateLink(linkInfos)

                kiss.loadingSpinner.hide(loadingId)
            } catch (err) {
                log("kiss.data.Record - linkTo - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
            }
        }

        /**
         * Delete a link between 2 records.
         * 
         * @ignore
         * @param {string} linkId - id of the record in the join table
         */
        async deleteLink(linkId) {
            let loadingId

            try {
                log(`kiss.data.Record - deleteLink ${this.id} / ${linkId}`)
                loadingId = kiss.loadingSpinner.show()

                const linkModel = kiss.app.models.link
                const linkRecord = await linkModel.collection.findOne(linkId)
                const linkInfos = await linkRecord.getData()

                const result = await linkRecord.delete()
                if (!result) {
                    kiss.loadingSpinner.hide(loadingId)
                    return false
                }

                // Re-compute all fields of both records without the link
                await this.db.updateLink(linkInfos)

                kiss.loadingSpinner.hide(loadingId)
                return result

            } catch (err) {
                log("kiss.data.Record - linkTo - Error:", 4, err)
                kiss.loadingSpinner.hide(loadingId)
            }
        }

        /**
         * Get the data and populate the fields linked to foreign records.
         * 
         * The function is recursive and explore all the records connections.
         * To avoid endless loops, each model that has already been explored is "skipped" from inner exploration.
         * In a future evolution, we may also allow exploration of the same model in inner exploration, but limiting it to a predefined depth.
         * 
         * @param {pbject} config
         * @param {boolean} [config.useLabels] - If true, use labels as exported keys. Default to false
         * @param {boolean} [config.convertNames] - If true, convert emails and groups ids to directory names
         * @param {boolean} [config.numberAsText] - If true, convert numbers to text with fixed number of digits according to the defined precision. Default to false
         * @param {boolean} [config.includeLinks] - If true, explore links and includes them as nested data. Default to false
         * @param {number} [config.linksDepth] - Maximum depth when exploring the links. Default to 1, meaning it only gets the direct relationships.
         * @param {boolean} [config.sortLinks] - If true, check if the links have been sorted by the user. Default to false
         * @param {string[]} [config.projection] - Keep only the fields specified in this array. All fields by default.
         * @param {string[]} [skipIds] - Model ids to skip in the exploration of nested data
         * @param {string} [accountId] - For server only: accountId allows to retrieve the right directory to merge directory fields
         * @returns {object} Record's data, like: {a: 1, b: 2}
         * 
         * @example
         * myRecord.getData() // {"aEf32x": "Bob", "e07d58": "Wilson"}
         * myRecord.getData({useLabels: true}) // {"First name": "Bob", "Last name": "Wilson"}
         * 
         */
        async getData(config = {}, skipIds, accountId) {
            const recordData = {
                id: this.id
            }

            // Update the list of models that must be skipped when "link" fields are populated
            const modelId = this.model.id
            const skipModelIds = (Array.isArray(skipIds)) ? skipIds : []
            skipModelIds.push(modelId)

            // Update the current depth
            config.linksDepth = (config.linksDepth != undefined) ? config.linksDepth : 1
            const depth = config.linksDepth - 1
            const newConfig = Object.assign({}, config, {
                linksDepth: depth
            })
            
            let fields = this.model.fields.filter(field => !field.deleted)
            if (config.projection) {
                fields = fields.filter(field => config.projection.includes(field.id) || config.projection.includes(field.label))
            }

            for (let field of fields) {
                const fieldLabel = (config.useLabels == true) ? field.label : (field.id || field.label)

                // Link fields are populated with the linked records values
                if (field.type == "link") {

                    // Exploration of the relationships is limited to the defined depth
                    if (config.includeLinks == true && depth >= 0) {
                        const linkedModelId = field.link.modelId

                        // To avoid endless loop, We can't explore the same model twice
                        if (!skipModelIds.includes(linkedModelId)) {
                            let sort
                            if (config.sortLinks) sort = await this._getLinkFieldSortConfig(linkedModelId, field.id)
                            const links = await kiss.data.relations.getLinksAndRecords(modelId, this.id, field.id, sort)
                            const linkedRecords = links.map(link => link.record)
                            const connectedRecords = []

                            // For each linked record, try to get data recursively
                            for (let linkData of linkedRecords) {
                                const linkedRecord = kiss.app.models[linkedModelId].create(linkData)
                                const linkedRecordData = await linkedRecord.getData(newConfig, skipModelIds, accountId)
                                connectedRecords.push(linkedRecordData)
                            }
                            recordData[fieldLabel] = connectedRecords
                        }
                    } else {
                        recordData[fieldLabel] = []
                    }
                } else {
                    let value = this[field.id]

                    if (value !== "" && config.numberAsText && (field.type == "number" || (field.type == "lookup" && field.lookup.type == "number") || (field.type == "summary" && field.summary.type == "number"))) {
                        // Cast number to text with fixed precision
                        const precision = (field.precision != undefined) ? field.precision : 2
                        value = Number(value).round(precision).toFixed(precision)

                    } else if (config.convertNames && field.type == "directory") {
                        // Cast user id fields to directory names
                        if (kiss.isClient) {
                            value = (!value) ? [] : kiss.directory.getEntryNames([].concat(value))
                        } else {
                            value = (!value) ? [] : kiss.directory.getEntryNames(accountId, [].concat(value))
                        }

                    } else if (field.exporter && typeof field.exporter === "function") {
                        // If a plugin field has a special exporter, we use it
                        value = field.exporter(value)
                    }

                    if (value == undefined) value = ""
                    recordData[fieldLabel] = value
                }
            }

            return recordData
        }

        /**
         * Get the view configuration associated to a link field, if any
         * 
         * @private
         * @ignore
         * @param {string} modelId 
         * @param {string} fieldId 
         * @returns {object[]} The sort configuration (normalized), or null
         */
        async _getLinkFieldSortConfig(modelId, fieldId) {
            const viewRecord = await kiss.db.findOne("view", {
                modelId,
                fieldId
            })
            if (viewRecord && viewRecord.sort) return viewRecord.sort
            return null
        }        

        /**
         * Get the files attached to the record
         * 
         * @returns {object[]} The list of file objects
         * 
         * @example
         * [
         *     {
         *         "id": "dbba41cc-6ec6-4bb9-981a-4e27eafb20b9",
         *         "filename": "logo 8.png",
         *         "path": "https://pickaform-europe.s3.eu-west-3.amazonaws.com/files/a50616e1-8cce-4788-ae4e-7ee10d35b5f2/2022/06/17/logo%208.png",
         *         "bucket": "pickaform-europe",
         *         "key": "files/a50616e1-8cce-4788-ae4e-7ee10d35b5f2/2022/06/17/logo%208.png",
         *         "s3Endpoint": "s3.eu-west-3.amazonaws.com",
         *         "size": 7092,
         *         "type": "s3", // Means any compatible s3 storage (AWS, ScaleWay, etc.)
         *         "mimeType": "image/png",
         *         "thumbnails": {
         *              // Thumbnails infos
         *         },
         *         "createdAt": "2022-06-16T20:49:29.349Z",
         *         "createdBy": "john.doe@pickaform.com"
         *     },
         *     {
         *         "id": "0185c4f3-e3ff-7933-a1f2-e06459111665",
         *         "filename": "France invest.PNG",
         *         "path": "uploads\\01847546-a751-7a6e-9e6a-42b8b8e37570\\2023\\01\\18\\France invest.PNG",
         *         "size": 75999,
         *         "type": "local",
         *         "mimeType": "image/png",
         *         "thumbnails": {
         *              // Thumbnails infos
         *         },
         *         "createdAt": "2023-01-18T12:56:36.095Z",
         *         "createdBy": "georges.lucas@pickaform.com"
         *      }
         * ]
         */
        getFiles() {
            const attachmentFields = this.model.getFieldsByType("attachment").filter(field => !field.deleted)
            return attachmentFields.filter(field => this[field.id] !== undefined).map(field => this[field.id]).flat()
        }

        /**
         * Get record's raw data.
         * 
         * @returns {object}
         */
        getRawData() {
            return kiss.tools.snapshot(this)
        }

        /**
         * Compute the local computed fields when initializing a record.
         * lookup and summary fields are excluded because they are necessarily empty for a blank record.
         * 
         * @private
         * @ignore
         * @param {string} updatedFieldId 
         * @param {number} depth 
         */
        _computeFields_v1_deprecated(updatedFieldId, depth = 0) {
            if (depth > 10) return
            depth++

            for (let computedFieldId of this.model.computedFields) {
                const computedField = this.model.getField(computedFieldId)
                const computedFieldCurrentValue = this[computedField.id]

                if (computedFieldId != updatedFieldId // Don't recompute the same field 
                    &&
                    computedField.type != "lookup" // New records have no links => no lookups
                    &&
                    computedField.type != "summary" // New records have no links => no summary
                    &&
                    (!updatedFieldId || computedField.formulaSourceFieldIds.includes(updatedFieldId))
                ) {
                    let newComputedFieldValue = this._computeField(computedField)
                    if (newComputedFieldValue !== undefined && newComputedFieldValue !== computedFieldCurrentValue) {
                        // If the field's value changed, we propagate it to all form fields (except the field itself)
                        this[computedField.id] = newComputedFieldValue
                        this._computeFields(computedField.id, depth)
                    }
                }
            }
        }

        /**
         * Compute the local computed fields when initializing a record.
         * 
         * - exit immediately if the model has cyclic dependencies
         * - lookup and summary fields are excluded because they are necessarily empty for a blank record.
         * - fields are computed in their topological order
         */
        _computeFields() {
            if (this.model.hasCyclicDependencies) {
                log.warn("kiss.data.Record - _computeFields - The model fields have cyclic dependencies, the computed fields will not be computed.")
                return
            }

            for (let fieldId of this.model.orderedComputedFields) {
                const field = this.model.getField(fieldId)
                if (field.type == "lookup" || field.type == "summary") continue
                const newValue = this._computeField(field)
                if (newValue === undefined) continue
                if (kiss.tools.isNumericField(field) && isNaN(newValue)) continue
                this[fieldId] = newValue
            }
        }

        /**
         * Compute a single computed field
         * 
         * @private
         * @ignore
         * @param {object} field 
         * @returns The computed value, or "" in case of error
         */
        _computeField(field) {
            try {
                let newValue = kiss.formula.execute(field.formula, this, this.model.getActiveFields(), field)
                return newValue
            } catch (err) {
                log.err("kiss.data - Record.computeField - Error:", err)
                return ""
            }
        }
    }

    // Attach the Model's method to the Record's prototype.
    // This allows to use model's methods on every record instanciated from this Record class.
    const model = kiss.app.models[modelId]
    Object.keys(model.methods).forEach(methodName => {
        Record.prototype[methodName] = model.methods[methodName]
    })

    return Record
}

;