diff --git a/src/foco-live/foco-live.controller.ts b/src/foco-live/foco-live.controller.ts index 85ddc52..c3bf548 100644 --- a/src/foco-live/foco-live.controller.ts +++ b/src/foco-live/foco-live.controller.ts @@ -1,16 +1,26 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Logger, Query } from '@nestjs/common'; import { FocoLiveService } from './foco-live.service'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiQuery, ApiTags } from '@nestjs/swagger'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; +import { Gauge } from 'prom-client'; @ApiTags('foco-live') @Controller('foco-live') export class FocoLiveController { + private readonly logger = new Logger(FocoLiveController.name); + constructor( private readonly focoLiveService: FocoLiveService, + @InjectMetric('query_count') public queryCount: Gauge, ) { } @Get('events') - async getEvents() { - return this.focoLiveService.getEvents(); + @ApiQuery({ name: 'venue', required: false }) + @ApiQuery({ name: 'before', required: false, description: "Filter events that are before the provided date, any date format" }) + @ApiQuery({ name: 'after', required: false, description: "Filter events that are after the provided date, any date format" }) + async getEvents(@Query('venue') venue?: string, @Query('before') before?: string, @Query('after') after?: string) { + this.queryCount.inc(); + this.logger.verbose(`GET /foco-live/events?venue=${venue}&before=${before}&after=${after}`); + return this.focoLiveService.getEvents({ venue, before: before ? new Date(before) : undefined, after: after ? new Date(after) : undefined }); } } diff --git a/src/foco-live/foco-live.module.ts b/src/foco-live/foco-live.module.ts index b5344a4..25291f9 100644 --- a/src/foco-live/foco-live.module.ts +++ b/src/foco-live/foco-live.module.ts @@ -1,9 +1,33 @@ import { Module } from '@nestjs/common'; import { FocoLiveController } from './foco-live.controller'; import { FocoLiveService } from './foco-live.service'; +import { PrometheusModule, makeGaugeProvider } from '@willsoto/nestjs-prometheus'; +import { CacheModule } from '@nestjs/cache-manager'; @Module({ + imports: [ + CacheModule.register(), + PrometheusModule.register({ + customMetricPrefix: 'foco_live', + defaultMetrics: { + enabled: false, + }, + }) + ], controllers: [FocoLiveController], - providers: [FocoLiveService] + providers: [FocoLiveService, + makeGaugeProvider({ + name: 'event_count', + help: 'The current number of events in the Foco Live database at last query time', + }), + makeGaugeProvider({ + name: 'event_cache_misses', + help: 'The total number of cache misses for the Foco Live events', + }), + makeGaugeProvider({ + name: 'query_count', + help: 'The total number of queries to the Foco Live API', + }), + ] }) -export class FocoLiveModule {} +export class FocoLiveModule { } diff --git a/src/foco-live/foco-live.service.ts b/src/foco-live/foco-live.service.ts index a31128f..4645ee0 100644 --- a/src/foco-live/foco-live.service.ts +++ b/src/foco-live/foco-live.service.ts @@ -1,7 +1,12 @@ -import { Injectable } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { InjectMetric } from '@willsoto/nestjs-prometheus'; import * as Airtable from 'airtable'; import { AirtableBase } from 'airtable/lib/airtable_base'; +import { Cache } from 'cache-manager'; +import { Gauge } from 'prom-client'; +import { filter, pipe } from 'ramda'; const tables = { venues: 'tblRi4wDorKqNJJbs', @@ -10,24 +15,66 @@ const tables = { const compareDates = (a: any, b: any) => new Date(a.Date).getTime() - new Date(b.Date).getTime(); +const beforeFilter = (before?: Date) => (a: Event) => before ? new Date(a.Date) <= before : true; +const afterFilter = (after?: Date) => (a: Event) => after ? new Date(a.Date) >= after : true; + +export interface Event { + "Date": string, + "Music Start Time": string, + "Bar or Venue Name": string, + "Band/DJ/Musician Name": string, + Cost: string, + "Date Select": string +} + +const cacheKeys = { + allEvents: 'foco_live_events', +} + @Injectable() export class FocoLiveService { private readonly airtableBase: AirtableBase; + private readonly logger = new Logger(FocoLiveService.name); constructor( private readonly config: ConfigService, + @InjectMetric('event_count') public eventCount: Gauge, + @InjectMetric('event_cache_misses') public cacheMisses: Gauge, + @Inject(CACHE_MANAGER) private cacheManager: Cache ) { this.airtableBase = new Airtable({ apiKey: config.get('focoLive.airtable.apiKey'), }).base('app1SjPrn5qrhr59J'); } - async getEvents() { - return (await this.airtableBase('Events').select({ + async getAllEvents(): Promise { + return ((await this.airtableBase('Events').select({ view: "Grid view", }).all()) - .map(record => record.fields) + .map(record => record.fields) as any as Event[]) .sort(compareDates) .reverse(); } + + async getAllEventsCached(): Promise { + let events: Event[] | null | undefined = await this.cacheManager.get(cacheKeys.allEvents); + if (!events) { + events = await this.getAllEvents(); + this.cacheMisses.inc(); + this.cacheManager.set(cacheKeys.allEvents, events, 5 * 60 * 1000); + } + this.eventCount.set(events.length); + return events; + } + + async getEvents(options: { venue?: string, before?: Date, after?: Date } = {}) { + const events = await this.getAllEventsCached(); + const results = pipe( + filter((a: Event) => a["Bar or Venue Name"] === (options.venue ?? a["Bar or Venue Name"])), + filter(beforeFilter(options.before)), + filter(afterFilter(options.after)), + )(events); + this.logger.verbose(`Returning ${results.length} events, ${events.length} total events in database.`); + return results + } }