import { EmptyArgumentError, NetworkError, RequestError } from './errors'; import { createGQLSdk, GQLSdk } from './gqlClient'; import { CurrentGroupQuery, FeatureType } from './graphql/generated/gqlTypes'; import { KanaPublicApiKeyClientConfig, KanaGroupClientConfig, KanaGroupClientFullConfig, KanaGroupTokenClientConfig, } from './KanaGroupClientConfig'; import { request } from './request'; import { Consumption, Entitlement, Feature, Package, Group } from './types'; import { unique } from './utils/unique'; const maxRetries = 3; const defaultConfigOptions = { endpoint: 'https://client-api.usekana.com/graphql', version: '0.1', retry: (error: Error, retryNumber: number) => { return error instanceof NetworkError && retryNumber < maxRetries; }, }; export class KanaGroupClient { public readonly config: KanaGroupClientFullConfig; private readonly gqlSdk: GQLSdk; private _groupCached = false; private _group?: Group = undefined; private _groupSubscribedPackages: Package[] = []; private _groupSubscribedFeatures: Feature[] = []; private _groupFeatureConsumptions: Map = new Map(); constructor(config: KanaGroupClientConfig) { if (!config) { throw new EmptyArgumentError('config'); } if ((config as KanaGroupTokenClientConfig).groupToken) { this.config = { ...defaultConfigOptions, ...(config as KanaGroupTokenClientConfig), type: 'GroupToken', }; } else if ((config as KanaPublicApiKeyClientConfig).apiKey) { if (!(config as KanaPublicApiKeyClientConfig).groupId) { throw new Error( 'Kana config error, "groupId" is required when "apiKey" is used.', ); } this.config = { ...defaultConfigOptions, ...(config as KanaPublicApiKeyClientConfig), type: 'PublicApiKey', }; } else { throw new Error( 'Kana config error, "groupToken" or "apiKey" is required for client initialization.', ); } this.gqlSdk = createGQLSdk(this.config); } async resetCache() { await request(this.config, async () => { const groupCache = await this.gqlSdk.CurrentGroup(); this.updateGroupFields(groupCache); }); } async getGroup() { return request(this.config, async () => { await this.initGroupCache(); return this._group; }); } async getSubscribedPackages() { return request(this.config, async () => { await this.initGroupCache(); return this._groupSubscribedPackages; }); } async getSubscribedFeatures() { return request(this.config, async () => { await this.initGroupCache(); return this._groupSubscribedFeatures; }); } async canUseFeature(featureId: string, delta?: number) { return request(this.config, async () => { await this.initGroupCache(); const feature = this._groupSubscribedFeatures.find( (f) => f.id === featureId, ); if (feature) { if (feature.type === FeatureType.Binary) { return { access: true, reason: 'The group has subscribed to a package with this binary feature.', }; } else if (feature.type === FeatureType.Consumable) { const consumption = this._groupFeatureConsumptions.get(featureId); if (consumption) { const calculatedUsed = delta ? delta - 1 + consumption.used : consumption.used; const access = // unlimited budget or overage is allowed consumption.budget === null || consumption.overageEnabled ? true : calculatedUsed < consumption.budget; return { access, consumption, reason: access ? 'The group has a subcription to a package with this consumable feature and either has an allowance remaining or overage is enabled.' : 'The group has no remanining allowance of this feature and overage is not enabled.', }; } } } return { access: false, reason: 'The group has no active subscription to the feature.', }; }); } private async initGroupCache() { if (!this._groupCached) { const cache = await this.gqlSdk.CurrentGroup(); this.updateGroupFields(cache); this._groupCached = true; } } private updateGroupFields(cache: CurrentGroupQuery) { const currentGroup = cache.currentGroup; this._group = { id: currentGroup.id, email: currentGroup.email, name: currentGroup.name, metadata: currentGroup.metadata, }; this._groupSubscribedPackages = unique( currentGroup.subscriptions.map((sub) => ({ id: sub.package.id, name: sub.package.name, isAddon: sub.package.isAddon, metadata: sub.package.metadata, })), (p) => p.id, ); this._groupSubscribedFeatures = unique( currentGroup.subscriptions.flatMap((sub) => sub.package.features), (f) => f.id, ).map((f) => ({ id: f.id, name: f.name, type: f.type, metadata: f.metadata, unitLabel: f.unitLabel, unitLabelPlural: f.unitLabelPlural, })); const featureConsumptions = currentGroup.subscriptions .flatMap((sub) => sub.package.features.flatMap((f) => ({ feature: f, consumption: f.consumption, })), ) .reduce((agg, fetCon) => { const item = agg[fetCon.feature.id]; const consumptions = item?.consumptions || []; const consumption = fetCon.consumption as | Consumption | undefined | null; if (consumption) { consumptions.push(consumption); } agg[fetCon.feature.id] = { feature: fetCon.feature, consumptions: consumptions, }; return agg; }, {} as Record); this._groupFeatureConsumptions = new Map(); for (const featureId of Object.keys(featureConsumptions)) { const fetCons = featureConsumptions[featureId]; const aggConsumption = { budget: fetCons.consumptions.find((c) => c.budget === null) ? null : fetCons.consumptions.reduce((agg, c) => agg + (c.budget ?? 0), 0), used: fetCons.consumptions.reduce((agg, c) => agg + c.used, 0), overageEnabled: fetCons.consumptions.reduce( (agg, c) => agg || c.overageEnabled, false, ), }; this._groupFeatureConsumptions.set(featureId, aggConsumption); } } }