metrics.service.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. import { Injectable } from '@nestjs/common';
  2. import { assertNever } from '@vendure/common/lib/shared-utils';
  3. import { CacheService, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
  4. import { createHash } from 'crypto';
  5. import {
  6. Duration,
  7. endOfDay,
  8. getDayOfYear,
  9. getISOWeek,
  10. getMonth,
  11. setDayOfYear,
  12. startOfDay,
  13. sub,
  14. } from 'date-fns';
  15. import {
  16. AverageOrderValueMetric,
  17. MetricCalculation,
  18. OrderCountMetric,
  19. OrderTotalMetric,
  20. } from '../config/metrics-strategies.js';
  21. import { loggerCtx } from '../constants.js';
  22. import { MetricInterval, MetricSummary, MetricSummaryEntry, MetricSummaryInput } from '../types.js';
  23. export type MetricData = {
  24. date: Date;
  25. orders: Order[];
  26. };
  27. @Injectable()
  28. export class MetricsService {
  29. metricCalculations: MetricCalculation[];
  30. constructor(
  31. private connection: TransactionalConnection,
  32. private cacheService: CacheService,
  33. ) {
  34. this.metricCalculations = [
  35. new AverageOrderValueMetric(),
  36. new OrderCountMetric(),
  37. new OrderTotalMetric(),
  38. ];
  39. }
  40. async getMetrics(
  41. ctx: RequestContext,
  42. { interval, types, refresh }: MetricSummaryInput,
  43. ): Promise<MetricSummary[]> {
  44. // Set 23:59:59.999 as endDate
  45. const endDate = endOfDay(new Date());
  46. // Check if we have cached result
  47. const hash = createHash('sha1')
  48. .update(
  49. JSON.stringify({
  50. endDate,
  51. types: types.sort(),
  52. interval,
  53. channel: ctx.channel.token,
  54. }),
  55. )
  56. .digest('base64');
  57. const cacheKey = `MetricsService:${hash}`;
  58. const cachedMetricList = await this.cacheService.get<MetricSummary[]>(cacheKey);
  59. if (cachedMetricList && refresh !== true) {
  60. Logger.verbose(`Returning cached metrics for channel ${ctx.channel.token}`, loggerCtx);
  61. return cachedMetricList;
  62. }
  63. // No cache, calculating new metrics
  64. Logger.verbose(
  65. `No cache hit, calculating ${interval} metrics until ${endDate.toISOString()} for channel ${
  66. ctx.channel.token
  67. } for all orders`,
  68. loggerCtx,
  69. );
  70. const data = await this.loadData(ctx, interval, endDate);
  71. const metrics: MetricSummary[] = [];
  72. for (const type of types) {
  73. const metric = this.metricCalculations.find(m => m.type === type);
  74. if (!metric) {
  75. continue;
  76. }
  77. // Calculate entry (month or week)
  78. const entries: MetricSummaryEntry[] = [];
  79. data.forEach(dataPerTick => {
  80. entries.push(metric.calculateEntry(ctx, interval, dataPerTick));
  81. });
  82. // Create metric with calculated entries
  83. metrics.push({
  84. interval,
  85. title: metric.getTitle(ctx),
  86. type: metric.type,
  87. entries,
  88. });
  89. }
  90. await this.cacheService.set(cacheKey, metrics, { ttl: 1000 * 60 * 60 * 2 }); // 2 hours
  91. return metrics;
  92. }
  93. async loadData(
  94. ctx: RequestContext,
  95. interval: MetricInterval,
  96. endDate: Date,
  97. ): Promise<Map<number, MetricData>> {
  98. let nrOfEntries: number;
  99. let backInTimeAmount: Duration;
  100. const orderRepo = this.connection.getRepository(ctx, Order);
  101. // What function to use to get the current Tick of a date (i.e. the week or month number)
  102. let getTickNrFn: typeof getMonth | typeof getISOWeek;
  103. let maxTick: number;
  104. switch (interval) {
  105. case MetricInterval.Daily: {
  106. nrOfEntries = 30;
  107. backInTimeAmount = { days: nrOfEntries };
  108. getTickNrFn = getDayOfYear;
  109. maxTick = 365;
  110. break;
  111. }
  112. default:
  113. assertNever(interval);
  114. }
  115. const startDate = startOfDay(sub(endDate, backInTimeAmount));
  116. const startTick = getTickNrFn(startDate);
  117. // Get orders in a loop until we have all
  118. let skip = 0;
  119. const take = 1000;
  120. let hasMoreOrders = true;
  121. const orders: Order[] = [];
  122. while (hasMoreOrders) {
  123. const query = orderRepo
  124. .createQueryBuilder('order')
  125. .leftJoin('order.channels', 'orderChannel')
  126. .where('orderChannel.id=:channelId', { channelId: ctx.channelId })
  127. .andWhere('order.orderPlacedAt >= :startDate', {
  128. startDate: startDate.toISOString(),
  129. })
  130. .skip(skip)
  131. .take(take);
  132. const [items, nrOfOrders] = await query.getManyAndCount();
  133. orders.push(...items);
  134. Logger.verbose(
  135. `Fetched orders ${skip}-${skip + take} for channel ${
  136. ctx.channel.token
  137. } for ${interval} metrics`,
  138. loggerCtx,
  139. );
  140. skip += items.length;
  141. if (orders.length >= nrOfOrders) {
  142. hasMoreOrders = false;
  143. }
  144. }
  145. Logger.verbose(
  146. `Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for ${interval} metrics`,
  147. loggerCtx,
  148. );
  149. const dataPerInterval = new Map<number, MetricData>();
  150. const ticks = [];
  151. for (let i = 1; i <= nrOfEntries; i++) {
  152. if (startTick + i >= maxTick) {
  153. // make sure we don't go over month 12 or week 52
  154. ticks.push(startTick + i - maxTick);
  155. } else {
  156. ticks.push(startTick + i);
  157. }
  158. }
  159. ticks.forEach(tick => {
  160. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
  161. const ordersInCurrentTick = orders.filter(order => getTickNrFn(order.orderPlacedAt!) === tick);
  162. dataPerInterval.set(tick, {
  163. orders: ordersInCurrentTick,
  164. date: setDayOfYear(endDate, tick),
  165. });
  166. });
  167. return dataPerInterval;
  168. }
  169. }