Skip to content

Commit

Permalink
feat(admin-ui-plugin): Add simple metrics support via new metricSumma…
Browse files Browse the repository at this point in the history
…ry query
  • Loading branch information
michaelbromley committed May 30, 2023
1 parent be9b0a4 commit 717d265
Show file tree
Hide file tree
Showing 16 changed files with 639 additions and 4 deletions.
1 change: 1 addition & 0 deletions packages/admin-ui-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"typescript": "4.9.5"
},
"dependencies": {
"date-fns": "^2.30.0",
"fs-extra": "^10.0.0"
}
}
33 changes: 33 additions & 0 deletions packages/admin-ui-plugin/src/api/api-extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import gql from 'graphql-tag';

export const adminApiExtensions = gql`
type MetricSummary {
interval: MetricInterval!
type: MetricType!
title: String!
entries: [MetricSummaryEntry!]!
}
enum MetricInterval {
Daily
}
enum MetricType {
OrderCount
OrderTotal
AverageOrderValue
}
type MetricSummaryEntry {
label: String!
value: Float!
}
input MetricSummaryInput {
interval: MetricInterval!
types: [MetricType!]!
refresh: Boolean
}
extend type Query {
"""
Get metrics for the given interval and metric types.
"""
metricSummary(input: MetricSummaryInput): [MetricSummary!]!
}
`;
19 changes: 19 additions & 0 deletions packages/admin-ui-plugin/src/api/metrics.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Args, Query, Resolver } from '@nestjs/graphql';
import { Allow, Ctx, Permission, RequestContext } from '@vendure/core';

import { MetricsService } from '../service/metrics.service';
import { MetricSummary, MetricSummaryInput } from '../types';

@Resolver()
export class MetricsResolver {
constructor(private service: MetricsService) {}

@Query()
@Allow(Permission.ReadOrder)
async metricSummary(
@Ctx() ctx: RequestContext,
@Args('input') input: MetricSummaryInput,
): Promise<MetricSummary[]> {
return this.service.getMetrics(ctx, input);
}
}
87 changes: 87 additions & 0 deletions packages/admin-ui-plugin/src/config/metrics-strategies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { RequestContext } from '@vendure/core';

import { MetricData } from '../service/metrics.service';
import { MetricInterval, MetricSummaryEntry, MetricType } from '../types';

/**
* Calculate your metric data based on the given input.
* Be careful with heavy queries and calculations,
* as this function is executed everytime a user views its dashboard
*
*/
export interface MetricCalculation {
type: MetricType;

getTitle(ctx: RequestContext): string;

calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry;
}

export function getMonthName(monthNr: number): string {
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return monthNames[monthNr];
}

/**
* Calculates the average order value per month/week
*/
export class AverageOrderValueMetric implements MetricCalculation {
readonly type = MetricType.AverageOrderValue;

getTitle(ctx: RequestContext): string {
return 'average-order-value';
}

calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
const label = data.date.toISOString();
if (!data.orders.length) {
return {
label,
value: 0,
};
}
const total = data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current);
const average = Math.round(total / data.orders.length);
return {
label,
value: average,
};
}
}

/**
* Calculates number of orders
*/
export class OrderCountMetric implements MetricCalculation {
readonly type = MetricType.OrderCount;

getTitle(ctx: RequestContext): string {
return 'order-count';
}

calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
const label = data.date.toISOString();
return {
label,
value: data.orders.length,
};
}
}
/**
* Calculates order total
*/
export class OrderTotalMetric implements MetricCalculation {
readonly type = MetricType.OrderTotal;

getTitle(ctx: RequestContext): string {
return 'order-totals';
}

calculateEntry(ctx: RequestContext, interval: MetricInterval, data: MetricData): MetricSummaryEntry {
const label = data.date.toISOString();
return {
label,
value: data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current, 0),
};
}
}
9 changes: 8 additions & 1 deletion packages/admin-ui-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@ import express from 'express';
import fs from 'fs-extra';
import path from 'path';

import { adminApiExtensions } from './api/api-extensions';
import { MetricsResolver } from './api/metrics.resolver';
import {
defaultAvailableLanguages,
defaultLanguage,
defaultLocale,
DEFAULT_APP_PATH,
loggerCtx,
} from './constants';
import { MetricsService } from './service/metrics.service';

/**
* @description
Expand Down Expand Up @@ -102,7 +105,11 @@ export interface AdminUiPluginOptions {
*/
@VendurePlugin({
imports: [PluginCommonModule],
providers: [],
adminApiExtensions: {
schema: adminApiExtensions,
resolvers: [MetricsResolver],
},
providers: [MetricsService],
compatibility: '^2.0.0-beta.0',
})
export class AdminUiPlugin implements NestModule {
Expand Down
173 changes: 173 additions & 0 deletions packages/admin-ui-plugin/src/service/metrics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { assertNever } from '@vendure/common/lib/shared-utils';
import {
ConfigService,
Logger,
Order,
RequestContext,
TransactionalConnection,
TtlCache,
} from '@vendure/core';
import {
Duration,
endOfDay,
getDayOfYear,
getISOWeek,
getMonth,
setDayOfYear,
startOfDay,
sub,
} from 'date-fns';

import {
AverageOrderValueMetric,
MetricCalculation,
OrderCountMetric,
OrderTotalMetric,
} from '../config/metrics-strategies';
import { loggerCtx } from '../constants';
import { MetricInterval, MetricSummary, MetricSummaryEntry, MetricSummaryInput } from '../types';

export type MetricData = {
date: Date;
orders: Order[];
};

@Injectable()
export class MetricsService {
private cache = new TtlCache<string, MetricSummary[]>({ ttl: 1000 * 60 * 60 * 24 });
metricCalculations: MetricCalculation[];
constructor(private connection: TransactionalConnection, private configService: ConfigService) {
this.metricCalculations = [
new AverageOrderValueMetric(),
new OrderCountMetric(),
new OrderTotalMetric(),
];
}

async getMetrics(
ctx: RequestContext,
{ interval, types, refresh }: MetricSummaryInput,
): Promise<MetricSummary[]> {
// Set 23:59:59.999 as endDate
const endDate = endOfDay(new Date());
// Check if we have cached result
const cacheKey = JSON.stringify({
endDate,
types: types.sort(),
interval,
channel: ctx.channel.token,
});
const cachedMetricList = this.cache.get(cacheKey);
if (cachedMetricList && refresh !== true) {
Logger.verbose(`Returning cached metrics for channel ${ctx.channel.token}`, loggerCtx);
return cachedMetricList;
}
// No cache, calculating new metrics
Logger.verbose(
`No cache hit, calculating ${interval} metrics until ${endDate.toISOString()} for channel ${
ctx.channel.token
} for all orders`,
loggerCtx,
);
const data = await this.loadData(ctx, interval, endDate);
const metrics: MetricSummary[] = [];
for (const type of types) {
const metric = this.metricCalculations.find(m => m.type === type);
if (!metric) {
continue;
}
// Calculate entry (month or week)
const entries: MetricSummaryEntry[] = [];
data.forEach(dataPerTick => {
entries.push(metric.calculateEntry(ctx, interval, dataPerTick));
});
// Create metric with calculated entries
metrics.push({
interval,
title: metric.getTitle(ctx),
type: metric.type,
entries,
});
}
this.cache.set(cacheKey, metrics);
return metrics;
}

async loadData(
ctx: RequestContext,
interval: MetricInterval,
endDate: Date,
): Promise<Map<number, MetricData>> {
let nrOfEntries: number;
let backInTimeAmount: Duration;
const orderRepo = this.connection.getRepository(ctx, Order);
// What function to use to get the current Tick of a date (i.e. the week or month number)
let getTickNrFn: typeof getMonth | typeof getISOWeek;
let maxTick: number;
switch (interval) {
case MetricInterval.Daily: {
nrOfEntries = 30;
backInTimeAmount = { days: nrOfEntries };
getTickNrFn = getDayOfYear;
maxTick = 365;
break;
}
default:
assertNever(interval);
}
const startDate = startOfDay(sub(endDate, backInTimeAmount));
const startTick = getTickNrFn(startDate);
// Get orders in a loop until we have all
let skip = 0;
const take = 1000;
let hasMoreOrders = true;
const orders: Order[] = [];
while (hasMoreOrders) {
const query = orderRepo
.createQueryBuilder('order')
.leftJoin('order.channels', 'orderChannel')
.where('orderChannel.id=:channelId', { channelId: ctx.channelId })
.andWhere('order.orderPlacedAt >= :startDate', {
startDate: startDate.toISOString(),
})
.skip(skip)
.take(take);
const [items, nrOfOrders] = await query.getManyAndCount();
orders.push(...items);
Logger.info(
`Fetched orders ${skip}-${skip + take} for channel ${
ctx.channel.token
} for ${interval} metrics`,
loggerCtx,
);
skip += items.length;
if (orders.length >= nrOfOrders) {
hasMoreOrders = false;
}
}
Logger.verbose(
`Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for ${interval} metrics`,
loggerCtx,
);
const dataPerInterval = new Map<number, MetricData>();
const ticks = [];
for (let i = 1; i <= nrOfEntries; i++) {
if (startTick + i >= maxTick) {
// make sure we dont go over month 12 or week 52
ticks.push(startTick + i - maxTick);
} else {
ticks.push(startTick + i);
}
}
ticks.forEach(tick => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ordersInCurrentTick = orders.filter(order => getTickNrFn(order.orderPlacedAt!) === tick);
dataPerInterval.set(tick, {
orders: ordersInCurrentTick,
date: setDayOfYear(endDate, tick),
});
});
return dataPerInterval;
}
}
29 changes: 29 additions & 0 deletions packages/admin-ui-plugin/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ID } from '@vendure/core';

export type MetricSummary = {
interval: MetricInterval;
type: MetricType;
title: string;
entries: MetricSummaryEntry[];
};

export enum MetricType {
OrderCount = 'OrderCount',
OrderTotal = 'OrderTotal',
AverageOrderValue = 'AverageOrderValue',
}

export enum MetricInterval {
Daily = 'Daily',
}

export type MetricSummaryEntry = {
label: string;
value: number;
};

export interface MetricSummaryInput {
interval: MetricInterval;
types: MetricType[];
refresh?: boolean;
}
Loading

0 comments on commit 717d265

Please sign in to comment.