TryGhost/Ghost

View on GitHub
ghost/admin/mirage/config/members.js

Summary

Maintainability
D
2 days
Test Coverage
import moment from 'moment-timezone';
import nql from '@tryghost/nql';
import {Response} from 'miragejs';
import {
    extractFilterParam,
    paginateModelCollection,
    withPermissionsCheck
} from '../utils';
import {faker} from '@faker-js/faker';
import {underscore} from '@ember/string';

const ALLOWED_ROLES = [
    'Owner',
    'Administrator'
];

export function mockMembersStats(server) {
    server.get('/members/stats/count', withPermissionsCheck(ALLOWED_ROLES, function (db, {queryParams}) {
        let {days} = queryParams;

        let firstSubscriberDays = faker.datatype.number({min: 30, max: 600});

        if (days === 'all-time') {
            days = firstSubscriberDays;
        } else {
            days = Number(days);
        }

        let total = 0;
        if (firstSubscriberDays > days) {
            total += faker.datatype.number({max: 1000});
        }

        // simulate sql GROUP BY where days with 0 subscribers are missing
        let dateCounts = {};
        let i = 0;
        while (i < days) {
            let date = moment().subtract(i, 'days').format('YYYY-MM-DD');
            let count = faker.datatype.number({min: 0, max: 30});

            if (count !== 0) {
                dateCounts[date] = count;
            }

            i += 1;
        }

        // similar to what we'll need to do on the server
        let totalOnDate = {};
        let j = days - 1;
        while (j >= 0) {
            let date = moment().subtract(j, 'days').format('YYYY-MM-DD');
            totalOnDate[date] = total + (dateCounts[date] || 0);
            total += (dateCounts[date] || 0);
            j -= 1;
        }

        return {
            total,
            resource: 'members',
            data: Object.keys(totalOnDate).map((key, idx, arr) => {
                return {
                    date: key,
                    free: arr[key],
                    paid: 0,
                    comped: 0
                };
            })
        };
    }));
}

export default function mockMembers(server) {
    server.post('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}) {
        const attrs = this.normalizedRequestAttrs();
        return members.create(attrs);
    }));

    server.get('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {queryParams}) {
        let {filter, search, page, limit} = queryParams;
        page = +page || 1;
        limit = +limit || 15;

        let collection = members.all();
        if (filter) {
            try {
                const nqlFilter = nql(filter, {
                    expansions: [
                        {
                            key: 'label',
                            replacement: 'labels.slug'
                        },
                        {
                            key: 'tier',
                            replacement: 'tiers.slug'
                        },
                        {
                            key: 'tier_id',
                            replacement: 'tiers.id'
                        },
                        {
                            key: 'offer_redemptions',
                            replacement: 'subscriptions.offer_id'
                        }
                    ]
                });

                collection = collection.filter((member) => {
                    const serializedMember = {};

                    // mirage model keys match our main model keys so we need to transform
                    // camelCase to underscore to match the filter format
                    Object.keys(member.attrs).forEach((key) => {
                        serializedMember[underscore(key)] = member.attrs[key];
                    });

                    // similar deal for associated models
                    ['labels', 'tiers', 'subscriptions', 'newsletters'].forEach((association) => {
                        serializedMember[association] = [];
                        member[association].models.forEach((associatedModel) => {
                            const serializedAssociation = {};
                            Object.keys(associatedModel.attrs).forEach((key) => {
                                serializedAssociation[underscore(key)] = associatedModel.attrs[key];
                            });
                            serializedMember[association].push(serializedAssociation);
                        });
                    });
                    return nqlFilter.queryJSON(serializedMember);
                });
            } catch (err) {
                console.error(err); // eslint-disable-line
                throw err;
            }
        }

        if (search) {
            const query = search.toLowerCase();
            collection = collection.filter((member) => {
                return member.name.toLowerCase().indexOf(query) !== -1
                    || member.email.toLowerCase().indexOf(query) !== -1;
            });
        }

        return paginateModelCollection('members', collection, page, limit);
    }));

    server.del('/members/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {queryParams}) {
        if (!queryParams.filter && !queryParams.search && queryParams.all !== 'true') {
            return new Response(422, {}, {errors: [{
                type: 'IncorrectUsageError',
                message: 'DELETE /members/ must be used with a filter, search, or all=true query parameter'
            }]});
        }

        let membersToDelete = members.all();

        if (queryParams.filter) {
            let labelFilter = extractFilterParam('label', queryParams.filter);

            membersToDelete = membersToDelete.filter((member) => {
                let matches = false;
                labelFilter.forEach((slug) => {
                    if (member.labels.models.find(l => l.slug === slug)) {
                        matches = true;
                    }
                });
                return matches;
            });
        }

        let count = membersToDelete.length;
        membersToDelete.destroy();

        return {
            meta: {
                stats: {
                    successful: count
                }
            }
        };
    }));

    server.get('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, {params}) {
        let {id} = params;
        let member = members.find(id);

        return member || new Response(404, {}, {
            errors: [{
                type: 'NotFoundError',
                message: 'Member not found.'
            }]
        });
    }));

    server.put('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members, tiers, subscriptions}, {params}) {
        const attrs = this.normalizedRequestAttrs();
        const member = members.find(params.id);

        // API accepts `tiers: [{id: 'x'}]` which isn't handled natively by mirage
        if (attrs.tiers.length > 0) {
            attrs.tiers.forEach((p) => {
                const tier = tiers.find(p.id);

                if (!member.tiers.includes(tier)) {
                    // TODO: serialize tiers through _active_ subscriptions
                    member.tiers.add(tier);

                    subscriptions.create({
                        member,
                        tier,
                        comped: true,
                        plan: {
                            id: '',
                            nickname: 'Complimentary',
                            interval: 'year',
                            currency: 'USD',
                            amount: 0
                        },
                        status: 'active',
                        startDate: moment().toISOString(),
                        defaultPaymentCardLast4: '****',
                        cancelAtPeriodEnd: false,
                        cancellationReason: null,
                        currentPeriodEnd: moment().add(1, 'year').toISOString(),
                        price: {
                            id: '',
                            price_id: '',
                            nickname: 'Complimentary',
                            amount: 0,
                            interval: 'year',
                            type: 'recurring',
                            currency: 'USD',
                            tier: {
                                id: '',
                                tier_id: tier.id
                            }
                        },
                        offer: null
                    });

                    member.save();
                }
            });
        }

        const tierIds = (attrs.tiers || []).map(p => p.id);

        member.tiers.models.forEach((tier) => {
            if (!tierIds.includes(tier.id)) {
                member.subscriptions.models.filter(sub => sub.tier.id === tier.id).forEach((sub) => {
                    member.subscriptions.remove(sub);
                });

                member.tiers.remove(tier);
            }
        });

        // these are read-only properties so make sure we don't overwrite data
        delete attrs.tiers;
        delete attrs.subscriptions;

        return member.update(attrs);
    }));

    server.del('/members/:id/', withPermissionsCheck(ALLOWED_ROLES, function ({members}, request) {
        const id = request.params.id;
        members.find(id).destroy();
    }));

    server.get('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function () {
        return new Response(200, {
            'Content-Disposition': 'attachment',
            filename: `members.${moment().format('YYYY-MM-DD')}.csv`,
            'Content-Type': 'text/csv'
        }, '');
    }));

    server.post('/members/upload/', withPermissionsCheck(ALLOWED_ROLES, function ({labels}, request) {
        const label = labels.create();

        // TODO: parse CSV and create member records
        for (const kvPair of request.requestBody.entries()) {
            const [key, value] = kvPair;
            console.log({key, value}); // eslint-disable-line
        }

        return new Response(201, {}, {
            meta: {
                import_label: label,
                stats: {imported: 1, invalid: []}
            }
        });
    }));

    server.get('/members/events/', withPermissionsCheck(ALLOWED_ROLES, function ({memberActivityEvents}, {queryParams}) {
        let {limit, filter, page} = queryParams;

        limit = +limit || 15;
        page = +page || 1;

        let collection = memberActivityEvents.all();
        collection = collection.sort((a, b) => {
            return Number(b.id) - Number(a.id);
        });

        if (filter) {
            try {
                const nqlFilter = nql(filter, {
                    expansions: [
                        {
                            key: 'data.created_at',
                            replacement: 'created_at'
                        }
                    ]
                });

                collection = collection.filter((event) => {
                    const serializedEvent = {};

                    // mirage model keys match our main model keys, so we need to transform
                    // camelCase to underscore to match the filter format
                    Object.keys(event.attrs).forEach((key) => {
                        serializedEvent[underscore(key)] = event.attrs[key];
                    });

                    return nqlFilter.queryJSON(serializedEvent);
                });
            } catch (err) {
                console.error(err); // eslint-disable-line
                throw err;
            }
        }

        return paginateModelCollection('members', collection, page, limit);
    }));

    mockMembersStats(server);
}