import type {Knex} from 'knex'; import type {CsvField} from '@tryghost/custom-field-types/csv'; import MembersCSVImporter, {type MembersRepository, type GiftService, type EmailNotifications, type Tier, type CustomFieldsImport} from './import/importer'; import readMemberRows from './import/reader'; import {createRowSpool} from './import/spool'; import MembersCSVExporter, {type ExportOptions, type CustomFieldDefinition} from './export/exporter'; const MembersCSVImporterStripeUtils = require('./import/stripe-utils'); const db = require('../../../data/db'); const models = require('../../../models'); const labs = require('../../../../shared/labs'); // The raw collaborators the members service hands the import composition root, before // they are adapted into the ports the importer declares. The members repository is the // importer's aggregate minus the import-label lookup, which the root folds in itself. interface ImporterServices { knex: Knex; getMembersRepository(): Promise>; getDefaultTier(): Promise; getTierByName(name: string): Promise; getGiftService(): { reassignRedeemer(input: {giftId: string; memberId: string; transacting?: Knex.Transaction}): Promise; }; sendEmail: EmailNotifications['send']; urlFor: EmailNotifications['urlFor']; addJob(job: {job: () => Promise; offloaded: boolean; name: string}): void; getTimezone(): string; getInlineThreshold(): number; stripeAPIService: unknown; productRepository: unknown; // The custom fields services the members service hands the import composition root. customFields: { definitions: {browse(): Promise}; values: { planWrite(values: Record): Promise; applyWrite(memberId: string, plan: unknown[], options?: {executor?: Knex}): Promise; }; }; } // The custom fields services the members service hands the export composition root. interface CustomFieldsServices { definitions: {browse(): Promise}; values: {getValuesForMembers(memberIds: string[]): Promise>>}; } // Build the members CSV importer. This is the composition root: today's models and // services are wired behind the collaborators the importer declares, one per // concern, so nothing Bookshelf-shaped leaks into the import service itself. export function makeImporter(deps: ImporterServices) { // The members repository resolves asynchronously and is stable once ready, so // cache the promise and reuse it across every call the import makes. let membersRepositoryPromise: Promise> | undefined; const getMembersRepository = () => (membersRepositoryPromise ??= deps.getMembersRepository()); // The members aggregate, plus the import label lookup folded in so the label // (member-tagging data) does not need a source of its own. const members: MembersRepository = { get: async (query, options) => (await getMembersRepository()).get(query, options), create: async (values, options) => (await getMembersRepository()).create(values, options), update: async (values, options) => (await getMembersRepository()).update(values, options), getCustomerIdByEmail: async email => (await getMembersRepository()).getCustomerIdByEmail(email), linkStripeCustomer: async (link, options) => (await getMembersRepository()).linkStripeCustomer(link, options), getImportLabel: name => models.Label.findOne({name}) }; // The completion email: its recipient, links and delivery in one collaborator. const email: EmailNotifications = { send: deps.sendEmail, getDefaultRecipient: async () => (await models.User.getOwnerUser()).get('email'), urlFor: deps.urlFor }; // Gifts is initialised at boot and always present at request time; the getter // resolves it lazily so the ready service is picked up whenever a row uses it. const gifts: GiftService = { reassignRedeemer: (giftId, memberId, options) => deps.getGiftService().reassignRedeemer({ giftId, memberId, transacting: options.transacting }) }; // Gated by the same labs flag as the export, so the two halves round-trip or stay // silent together: off, activeFields resolves empty and every custom_fields.* column // is dropped. const customFields: CustomFieldsImport = { activeFields: async () => (labs.isSet('membersCustomFields') ? deps.customFields.definitions.browse() : []), planWrite: values => deps.customFields.values.planWrite(values), applyWrite: (memberId, plan, executor) => deps.customFields.values.applyWrite(memberId, plan, {executor}) }; return new MembersCSVImporter({ knex: deps.knex, readRows: readMemberRows, spool: createRowSpool(), members, tiers: { getDefault: deps.getDefaultTier, getByName: deps.getTierByName }, stripe: new MembersCSVImporterStripeUtils({ stripeAPIService: deps.stripeAPIService, productRepository: deps.productRepository }), gifts, customFields, email, addJob: deps.addJob, getTimezone: deps.getTimezone, getInlineThreshold: deps.getInlineThreshold }); } // Build the members CSV exporter. The same composition root from the other direction: // knex and the members id lookup are wired here, and the custom fields definitions and // values services are injected (boot builds them before this one). The labs flag alone // decides whether custom field columns appear, so nothing flag-shaped leaks into the // exporter itself. export function makeExporter({definitions, values}: CustomFieldsServices): (options?: ExportOptions) => Promise { const exporter = new MembersCSVExporter({ knex: db.knex, members: { // Minimal query, only to fetch the ids of the filtered members; the stream // reads their related data itself. findFilteredIds: async (options) => { const page = await models.Member.findPage({...options, withRelated: [], columns: ['id'], limit: 'all'}); return page.data.map((member: {id: string}) => member.id); } }, customFields: { // Boot builds the definitions and values services before this one, so they // are always present -- no not-initialised state to guard. The flag decides // whether their columns are included at all. activeDefinitions: async (): Promise => (labs.isSet('membersCustomFields') ? definitions.browse() : []), valuesForMembers: memberIds => values.getValuesForMembers(memberIds) } }); return (options = {}) => exporter.export(options); }