TryGhost/Ghost

View on GitHub
ghost/admin/app/services/member-import-validator.js

Summary

Maintainability
B
4 hrs
Test Coverage
import Service, {inject as service} from '@ember/service';
import classic from 'ember-classic-decorator';
import validator from 'validator';
import {isEmpty} from '@ember/utils';

@classic
export default class MemberImportValidatorService extends Service {
    @service ajax;
    @service feature;
    @service membersUtils;

    @service ghostPaths;

    check(data) {
        let sampledData = this._sampleData(data);
        let mapping = this._detectDataTypes(sampledData);
        return mapping;
    }

    /**
     * Method implements following sampling logic:
     * Locate 10 non-empty cells from the start/middle(ish)/end of each column (30 non-empty values in total).
     * If the data contains 30 rows or fewer, all rows should be validated.
     *
     * @param {Array} data JSON objects mapped from CSV file
     * @param {number} validationSampleSize number of rows to sample
     */
    _sampleData(data, validationSampleSize = 30) {
        let validatedSet = [{}];

        if (data && data.length > validationSampleSize) {
            let sampleKeys = Object.keys(data[0]);

            sampleKeys.forEach(function (key) {
                const nonEmptyKeyEntries = data.filter(entry => !isEmpty(entry[key]));
                let sampledEntries = [];

                if (nonEmptyKeyEntries.length <= validationSampleSize) {
                    sampledEntries = nonEmptyKeyEntries;
                } else {
                    // take 3 equal parts from head, tail and middle of the data set
                    const partitionSize = validationSampleSize / 3;

                    const head = data.slice(0, partitionSize);
                    const tail = data.slice((data.length - partitionSize), data.length);

                    const middleIndex = Math.floor(data.length / 2);
                    const middleStartIndex = middleIndex - 2;
                    const middleEndIndex = middleIndex + 3;
                    const middle = data.slice(middleStartIndex, middleEndIndex);

                    validatedSet.push(...head);
                    validatedSet.push(...middle);
                    validatedSet.push(...tail);
                }

                sampledEntries.forEach((entry, index) => {
                    if (!validatedSet[index]) {
                        validatedSet[index] = {};
                    }

                    validatedSet[index][key] = entry[key];
                });
            });
        } else {
            validatedSet = data;
        }

        return validatedSet;
    }

    /**
     * Detects supported data types and auto-detects following needed for validation: email
     *
     * Returned "mapping" object contains mappings that could be accepted by the API
     * to map validated types.
     * @param {Array} data sampled data containing non empty values
     */
    _detectDataTypes(data) {
        const supportedTypes = [
            'email',
            'name',
            'note',
            'subscribed_to_emails',
            'complimentary_plan',
            'stripe_customer_id',
            'labels',
            'created_at'
        ];

        if (this.feature.importMemberTier) {
            supportedTypes.push('import_tier');
        }

        const autoDetectedTypes = [
            'email'
        ];

        let mapping = {};
        let i = 0;
        // looping through all sampled data until needed data types are detected
        while (i <= (data.length - 1)) {
            if (mapping.email && mapping.stripe_customer_id) {
                break;
            }

            let entry = data[i];
            for (const [key, value] of Object.entries(entry)) {
                if (!mapping.email && validator.isEmail(value)) {
                    mapping.email = key;
                    continue;
                }

                if (!mapping.name && /name/.test(key)) {
                    mapping.name = key;
                    continue;
                }

                if (!mapping[key] && supportedTypes.includes(key) && !(autoDetectedTypes.includes(key))) {
                    mapping[key] = key;
                }
            }

            i += 1;
        }

        return mapping;
    }
}