From e3682dfae640ffbdf660dec6ce51d4e049dad959 Mon Sep 17 00:00:00 2001 From: Chip Wasson Date: Thu, 25 Apr 2024 08:08:23 -0600 Subject: [PATCH] Improvoe focolive, add job views --- src/foco-live/foco-live.service.ts | 8 ++ src/jobs/jobs.controller.ts | 119 +++++++++++++++++++++++++++-- src/jobs/jobs.service.ts | 85 ++++++++++++++++++--- views/jobs/add.hbs | 14 ++++ views/jobs/leaderboard.hbs | 21 +++++ views/jobs/stats.hbs | 15 ++++ 6 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 views/jobs/add.hbs create mode 100644 views/jobs/leaderboard.hbs create mode 100644 views/jobs/stats.hbs diff --git a/src/foco-live/foco-live.service.ts b/src/foco-live/foco-live.service.ts index 4645ee0..fa9beaa 100644 --- a/src/foco-live/foco-live.service.ts +++ b/src/foco-live/foco-live.service.ts @@ -1,6 +1,7 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Cron } from '@nestjs/schedule'; import { InjectMetric } from '@willsoto/nestjs-prometheus'; import * as Airtable from 'airtable'; import { AirtableBase } from 'airtable/lib/airtable_base'; @@ -23,6 +24,7 @@ export interface Event { "Music Start Time": string, "Bar or Venue Name": string, "Band/DJ/Musician Name": string, + "Cost Select": string; Cost: string, "Date Select": string } @@ -67,6 +69,12 @@ export class FocoLiveService { return events; } + @Cron("0 */5 * * * *") + async refreshEvents() { + this.logger.verbose("Refreshing events cache."); + await this.getAllEventsCached(); + } + async getEvents(options: { venue?: string, before?: Date, after?: Date } = {}) { const events = await this.getAllEventsCached(); const results = pipe( diff --git a/src/jobs/jobs.controller.ts b/src/jobs/jobs.controller.ts index f02e797..eeff362 100644 --- a/src/jobs/jobs.controller.ts +++ b/src/jobs/jobs.controller.ts @@ -1,6 +1,7 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { JobsService } from './jobs.service'; -import { ApiBody, ApiConsumes, ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Header, Headers, Param, Post, Render, Req, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { JobMetadata, JobsService } from './jobs.service'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; class ClaimCompleteDto { @@ -30,7 +31,7 @@ export class JobsController { private readonly jobsService: JobsService, ) { } - @Get(':jobName/stats') + @Get(':jobName/stats.json') @ApiParam({ name: 'jobName', required: true }) async getStats(@Param('jobName') jobName: string) { return { @@ -40,18 +41,105 @@ export class JobsController { }; } - @Get(':jobName/todo') + @Get(':jobName/stats') + @ApiParam({ name: 'jobName', required: true }) + @Render('jobs/stats') + async getStatsPage(@Param('jobName') jobName: string) { + return { + jobName, + todoCount: await this.jobsService.getTodoItemCount(jobName), + claimedCount: await this.jobsService.getClaimedItemCount(jobName), + doneCount: await this.jobsService.getDoneItemCount(jobName), + }; + } + + @Get(':jobName/todo.json') @ApiParam({ name: 'jobName', required: true }) async getTodoItems(@Param('jobName') jobName: string) { return this.jobsService.getTodoItems(jobName); } - @Get(':jobName/leaderboard') + @Get(':jobName/done.json') + @ApiParam({ name: 'jobName', required: true }) + async getDone(@Param('jobName') jobName: string) { + return this.jobsService.getDoneItems(jobName); + } + + @Get(':jobName/claimed.json') + @ApiParam({ name: 'jobName', required: true }) + async getClaimed(@Param('jobName') jobName: string) { + return this.jobsService.getClaimedItems(jobName); + } + + @Get(':jobName/leaderboard.json') @ApiParam({ name: 'jobName', required: true }) async getLeaderboard(@Param('jobName') jobName: string) { return this.jobsService.getLeaderboard(jobName); } + @Get(':jobName/leaderboard') + @ApiParam({ name: 'jobName', required: true }) + @Render('jobs/leaderboard') + async getLeaderboardPage(@Param('jobName') jobName: string) { + const leaderboard = await this.jobsService.getLeaderboard(jobName); + type LeaderboardItem = { name: string, count: number } + return { + jobName, + claims: Object.keys(leaderboard.claimCounts).map(claimer => ({ name: claimer, count: leaderboard.claimCounts[claimer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count), + completes: Object.keys(leaderboard.completeCounts).map(completer => ({ name: completer, count: leaderboard.completeCounts[completer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count), + } + } + + @Post(':jobName/register') + @ApiParam({ name: 'jobName', required: true }) + @ApiBody({ + description: "Job metadata", examples: + { + "example1": { + value: { + name: 'my-job', + description: 'My job description', + tags: ['tag1', 'tag2'], + createdBy: 'my-identity', + createdAt: '2021-01-01T00:00:00Z', + claimSecret: 'my-secret' + } + } + } + }) + @ApiConsumes('application/json') + async claimJobItemJson(@Param('jobName') jobName: string, @Body() body: JobMetadata) { + return this.jobsService.registerJob(jobName, body); + } + + @Get(':jobName.json') + @ApiParam({ name: 'jobName', required: true }) + async getJobMetadata(@Param('jobName') jobName: string) { + const metadata = await this.jobsService.getPublicJobMetadata(jobName); + if (!metadata) { + return { + error: 'Job not registered', + }; + } + return metadata; + } + + @Post(':jobName/clear-todo') + @ApiParam({ name: 'jobName', required: true }) + @ApiBearerAuth('x-claim-key') + async clearTodoItems(@Headers('x-claim-key') claimKey: string, @Param('jobName') jobName: string) { + return this.jobsService.clearTodoItems(jobName, claimKey); + } + + @Get(':jobName/add') + @ApiParam({ name: 'jobName', required: true }) + @Render('jobs/add') + async addItemsPage(@Param('jobName') jobName: string) { + return { + jobName, + }; + } + @Post(':jobName/add') @ApiParam({ name: 'jobName', required: true }) @ApiConsumes('text/plain') @@ -60,12 +148,29 @@ export class JobsController { return this.jobsService.addItemsToJob(jobName, items.split('\n')); } + @Post(':jobName/addFile') + @UseInterceptors(FileInterceptor('file')) + @ApiParam({ name: 'jobName', required: true }) + @ApiConsumes('multipart/form-data') + @ApiBody({ description: "File containing items to add, one per line separated by newline characters" }) + async addItemsToJobFromFile(@Param('jobName') jobName: string, @UploadedFile() file: Express.Multer.File) { + return this.jobsService.addItemsToJob( + jobName, + file.buffer.toString().split('\n') + ); + } + @Post(':jobName/claim') @ApiParam({ name: 'jobName', required: true }) @ApiConsumes('text/plain') @ApiBody({ type: String, description: "Claimer identity string" }) async claimJobItem(@Param('jobName') jobName: string, @Body() claimer: string) { - return this.jobsService.claimJobItem(jobName, claimer); + const item = await this.jobsService.claimJobItem(jobName, claimer); + return { + jobName, + claimer, + item + } } @Post(':jobName/complete') diff --git a/src/jobs/jobs.service.ts b/src/jobs/jobs.service.ts index e2124a9..c0e1011 100644 --- a/src/jobs/jobs.service.ts +++ b/src/jobs/jobs.service.ts @@ -4,6 +4,22 @@ import Redis from 'ioredis' import { InjectRedis } from '@liaoliaots/nestjs-redis'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; +import { aperture } from 'ramda'; + +export interface JobMetadata { + name: string; + description: string; + tags: string[]; + createdBy: string; + createdAt: string; + claimSecret: string; +} + +export const privateMetadataKeys = ['claimSecret']; + +export type PublicJobMetadata = Omit; + +type Leaderboard = { completeCounts: { [claimer: string]: number }, claimCounts: { [claimer: string]: number } } @Injectable() export class JobsService { @@ -13,6 +29,10 @@ export class JobsService { @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, ) { } + public static cleanJobMetadata(metadata: JobMetadata): PublicJobMetadata { + return Object.fromEntries(Object.entries(metadata).filter(([key]) => !privateMetadataKeys.includes(key))) as PublicJobMetadata; + } + private jobNameBuilder(jobName: string) { return `job:${jobName}`; } @@ -37,7 +57,11 @@ export class JobsService { return `claim:${jobName}:${claimer}`; } - private async getCompleteCounts(jobName: string) { + private claimerCountWildcardBuilder(jobName: string) { + return `claim:${jobName}:*`; + } + + private async getCompleteCounts(jobName: string): Promise<{ [claimer: string]: number }> { const keys = await this.redis.keys(`complete:${jobName}:*`); const counts = await Promise.all(keys.map(async key => { const count = await this.redis.get(key); @@ -55,7 +79,7 @@ export class JobsService { }, {}) } - private async getClaimCounts(jobName: string) { + private async getClaimCounts(jobName: string): Promise<{ [claimer: string]: number }> { const keys = await this.redis.keys(`claim:${jobName}:*`); const counts = await Promise.all(keys.map(async key => { const count = await this.redis.get(key); @@ -73,8 +97,8 @@ export class JobsService { }, {}); } - async getLeaderboard(jobName: string) { - const cachedLeaderboard = await this.cacheManager.get(`leaderboard:${jobName}`); + async getLeaderboard(jobName: string): Promise { + const cachedLeaderboard = await this.cacheManager.get(`leaderboard:${jobName}`); if (cachedLeaderboard) { return cachedLeaderboard; } @@ -86,24 +110,30 @@ export class JobsService { async addItemsToJob(jobName: string, items: string[]) { - await this.redis.rpush(this.todoListNameBuilder(jobName), ...items); + const apertureSize = 100 + for (const itemSubset of (items.length > apertureSize ? aperture(apertureSize, items) : [items])) { + await this.redis.rpush(this.todoListNameBuilder(jobName), ...itemSubset); + } } async claimJobItem(jobName: string, claimer: string): Promise { const jobItem = await this.redis.brpoplpush(this.todoListNameBuilder(jobName), this.claimedListNameBuilder(jobName), 10); - if (jobItem) { - await this.redis.rpush(this.jobNameBuilder(jobName), JSON.stringify({ item: jobItem, client: claimer })); + if (!jobItem) { + return null; } await this.redis.incr(this.claimerCountNameBuilder(jobName, claimer)); return jobItem; } async completeJobItem(jobName: string, jobItem: string, completer: string, data: any) { - await this.redis.lrem(this.claimedListNameBuilder(jobName), 1, jobItem); - await this.redis.lrem(this.todoListNameBuilder(jobName), 1, JSON.stringify({ item: jobItem, client: completer })); + const claimRemoveResult = await this.redis.lrem(this.claimedListNameBuilder(jobName), 1, jobItem); + if (claimRemoveResult === 0) { + return false; + } await this.redis.rpush(this.doneListNameBuilder(jobName), JSON.stringify({ item: jobItem, client: completer, data })); await this.redis.decr(this.claimerCountNameBuilder(jobName, completer)); await this.redis.incr(this.completeCountNameBuilder(jobName, completer)); + return true } async getTodoItems(jobName: string) { @@ -134,11 +164,19 @@ export class JobsService { return this.redis.keys('job:*'); } - async registerJob(jobName: string, metadata: any) { - await this.redis.set(this.jobNameBuilder(jobName), JSON.stringify(metadata)); + async isJobRegistered(jobName: string) { + return this.redis.exists(this.jobNameBuilder(jobName)); } - async getJobMetadata(jobName: string): Promise { + async registerJob(jobName: string, metadata: JobMetadata) { + if (await this.isJobRegistered(jobName)) { + return false; + } + await this.redis.set(this.jobNameBuilder(jobName), JSON.stringify(metadata)); + return true + } + + async getJobMetadata(jobName: string): Promise { const result = await this.redis.get(this.jobNameBuilder(jobName)) if (!result) { return null; @@ -146,11 +184,34 @@ export class JobsService { return JSON.parse(result) } + async getPublicJobMetadata(jobName: string): Promise { + const metadata = await this.getJobMetadata(jobName); + if (!metadata) { + return null; + } + return JobsService.cleanJobMetadata(metadata); + } + + async clearClaimerCounts(jobName: string) { + const keys = await this.redis.keys(this.claimerCountWildcardBuilder(jobName)); + await Promise.all(keys.map(key => this.redis.del(key))); + } + async resetClaimedItems(jobName: string) { const claimedItems = await this.getClaimedItems(jobName); for (const claimedItem of claimedItems) { await this.redis.rpoplpush(this.claimedListNameBuilder(jobName), this.todoListNameBuilder(jobName)); } + await this.clearClaimerCounts(jobName); + } + + async clearTodoItems(jobName: string, claimKey: string) { + const metadata = await this.getJobMetadata(jobName); + if (metadata === null || metadata.claimSecret !== claimKey) { + return false; + } + + await this.redis.del(this.todoListNameBuilder(jobName)); } } diff --git a/views/jobs/add.hbs b/views/jobs/add.hbs new file mode 100644 index 0000000..5ca381c --- /dev/null +++ b/views/jobs/add.hbs @@ -0,0 +1,14 @@ +
+

File will be split on newline characters

+
+ + +
+
+ +
+
\ No newline at end of file diff --git a/views/jobs/leaderboard.hbs b/views/jobs/leaderboard.hbs new file mode 100644 index 0000000..903bd7a --- /dev/null +++ b/views/jobs/leaderboard.hbs @@ -0,0 +1,21 @@ +
+
Job: {{jobName}}
+
+
+

Complete

+
    + {{#each completes as |item|}} +
  1. {{name}}: {{count}}
  2. + {{/each}} +
+
+
+

Claims

+
    + {{#each claims as |item|}} +
  1. {{name}}: {{count}}
  2. + {{/each}} +
+
+
+
\ No newline at end of file diff --git a/views/jobs/stats.hbs b/views/jobs/stats.hbs new file mode 100644 index 0000000..dde9517 --- /dev/null +++ b/views/jobs/stats.hbs @@ -0,0 +1,15 @@ +
+
Job: {{jobName}}
+
+
ToDo:
+
{{todoCount}}
+
+
+
Claimed:
+
{{claimedCount}}
+
+
+
Done:
+
{{doneCount}}
+
+
\ No newline at end of file