diff --git a/package.json b/package.json index 637b148..b3d7411 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "bull": "^4.11.5", "cache-manager": "^5.3.1", "cache-manager-redis-yet": "^4.1.2", + "cookie-parser": "^1.4.7", "fp-ts": "^2.16.3", "haversine-ts": "^1.2.0", "hbs": "^4.2.0", @@ -60,6 +61,7 @@ "@nestjs/testing": "^10.0.0", "@types/axios": "^0.14.0", "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.9", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", "@types/multer": "^1.4.10", diff --git a/src/app.module.ts b/src/app.module.ts index a2c05b7..6ee34b1 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -33,6 +33,7 @@ import { HoardingModule } from './hoarding/hoarding.module'; import { NamesModule } from './names/names.module'; import { AssemblyAiModule } from './assembly-ai/assembly-ai.module'; import { ClaudeModule } from './claude/claude.module'; +import { BabyNamesModule } from './baby-names/baby-names.module'; @Module({ imports: [ @@ -112,6 +113,7 @@ import { ClaudeModule } from './claude/claude.module'; NamesModule, AssemblyAiModule, ClaudeModule, + BabyNamesModule, ], controllers: [AppController], providers: [ diff --git a/src/baby-names/baby-names.controller.ts b/src/baby-names/baby-names.controller.ts new file mode 100644 index 0000000..364fb81 --- /dev/null +++ b/src/baby-names/baby-names.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Get, + Post, + Redirect, + Render, + Req, + Res, +} from '@nestjs/common'; +import { ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { BabyNamesService } from './baby-names.service'; +import { Request, Response } from 'express'; + +@Controller('baby-names') +@ApiTags('baby-names') +export class BabyNamesController { + constructor(private readonly babyNamesService: BabyNamesService) {} + + @Get() + @Render('baby-names/index') + async index(@Req() request: Request) { + const key = request.cookies['baby-names-key'] || null; + return { previousKey: key }; + } + + @Get('form') + @Render('baby-names/form') + async form(@Req() request: Request) { + const { key } = request.query; + if (!key) { + throw new Error("Missing 'key' field"); + } + const currentIndex = await this.babyNamesService.getCurrentNumber( + key as string, + ); + return { + key: key, + name: this.babyNamesService.nameList[currentIndex], + index: currentIndex, + message: request.query.message || null, + }; + } + + @Post() + @ApiConsumes('multipart/form-data') + @Redirect('/baby-names', 302) + async submit( + @Body() + body: { + key: string; + name: string; + nameindex: string; + opinion: string; + }, + @Res({ passthrough: true }) res: Response, + ) { + const { key, name, opinion, nameindex } = body; + if (!key) { + throw new Error("Missing 'key' field"); + } + await this.babyNamesService.addUserScore(key, name, parseInt(opinion, 10)); + await this.babyNamesService.writeUserNumber( + key, + parseInt(nameindex, 10) + 1, + ); + res.cookie('baby-names-key', key, { maxAge: 30 * 24 * 60 * 60 * 1000 }); // 30 days + return { + url: `/baby-names/form?key=${key}&message=Logged ${name} as ${opinion}`, + }; + } + + @Get('data/names.json') + async getNamesData() { + return { + names: this.babyNamesService.nameList, + nameCount: this.babyNamesService.nameCountMap, + }; + } +} diff --git a/src/baby-names/baby-names.module.ts b/src/baby-names/baby-names.module.ts new file mode 100644 index 0000000..063780c --- /dev/null +++ b/src/baby-names/baby-names.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { BabyNamesController } from './baby-names.controller'; +import { KvService } from 'src/kv/kv.service'; +import { MinioService } from 'src/minio/minio.service'; +import { BullModule } from '@nestjs/bull'; +import { BabyNamesService } from './baby-names.service'; + +@Module({ + providers: [KvService, MinioService, BabyNamesService], + imports: [], + controllers: [BabyNamesController], +}) +export class BabyNamesModule {} diff --git a/src/baby-names/baby-names.service.ts b/src/baby-names/baby-names.service.ts new file mode 100644 index 0000000..46d4451 --- /dev/null +++ b/src/baby-names/baby-names.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { KvService } from 'src/kv/kv.service'; +import { MinioService } from 'src/minio/minio.service'; + +export interface Name { + name: string; + year: number; + gender: string; + count: number; +} + +export interface NameMap { + [key: string]: Name[]; +} + +export type NameList = string[]; + +export interface NameCountMap { + [key: string]: number; +} + +@Injectable() +export class BabyNamesService { + public nameList: NameList = []; + public nameCountMap: NameCountMap = {}; + private readonly kvNamespace = 'baby-names'; + + constructor(private readonly minioService: MinioService) { + this.refreshNames(); + } + + private async refreshNames() { + this.nameCountMap = ( + await axios.get('https://cache.sh/baby-name-data/namecount.json') + ).data; + this.nameList = ( + await axios.get('https://cache.sh/baby-name-data/namecount-list.json') + ).data; + } + + public async getCurrentNumber(userKey: string): Promise { + const currentKey = await this.minioService + .getBuffer( + this.minioService.defaultBucketName, + `baby-names/${userKey}-current`, + ) + .then((buffer) => buffer.toString()) + .catch(() => null); + if (currentKey === null) { + await this.writeUserNumber(userKey, 0); + } + const currentNumber = parseInt(currentKey || '0', 10); + + return currentNumber + 1; + } + + public async writeUserNumber(userKey: string, number: number): Promise { + await this.minioService.uploadBuffer( + this.minioService.defaultBucketName, + `baby-names/${userKey}-current`, + Buffer.from(number.toString()), + ); + } + + public async getUserScores(userKey: string): Promise { + const scoresKey = await this.minioService + .getBuffer( + this.minioService.defaultBucketName, + `baby-names/${userKey}-scores`, + ) + .then((buffer) => buffer.toString()) + .catch(() => null); + if (scoresKey === null) { + return {}; + } + return JSON.parse(scoresKey); + } + + public async saveUserScores( + userKey: string, + scores: NameCountMap, + ): Promise { + await this.minioService.uploadBuffer( + this.minioService.defaultBucketName, + `baby-names/${userKey}-scores`, + Buffer.from(JSON.stringify(scores)), + ); + } + + public async addUserScore( + userKey: string, + name: string, + score: number, + ): Promise { + const scores = await this.getUserScores(userKey); + scores[name] = score; + await this.saveUserScores(userKey, scores); + } +} diff --git a/src/kv/kv.module.ts b/src/kv/kv.module.ts index f739e0e..565fdca 100644 --- a/src/kv/kv.module.ts +++ b/src/kv/kv.module.ts @@ -6,11 +6,7 @@ import { BullModule } from '@nestjs/bull'; @Module({ providers: [KvService, MinioService], - imports: [ - BullModule.registerQueue({ - name: 'kv', - }), - ], + imports: [], controllers: [KvController], }) export class KvModule {} diff --git a/src/kv/kv.service.ts b/src/kv/kv.service.ts index bc0bb78..c60d630 100644 --- a/src/kv/kv.service.ts +++ b/src/kv/kv.service.ts @@ -1,6 +1,4 @@ -import { InjectQueue } from '@nestjs/bull'; import { Injectable, Logger } from '@nestjs/common'; -import { Queue } from 'bull'; import { UploadedObjectInfo } from 'minio/dist/main/internal/type'; import { MinioService } from 'src/minio/minio.service'; @@ -12,10 +10,7 @@ export class KvService { private readonly kvMetadataPath = `${this.kvPrefix}/${this.kvMetadataFileName}`; private readonly logger: Logger = new Logger(KvService.name); - constructor( - private readonly minioService: MinioService, - @InjectQueue('kv') private kvProcessingQueue: Queue, - ) {} + constructor(private readonly minioService: MinioService) {} public generateFilePath(namespace: string, key: string): string { return `${this.kvPrefix}/${namespace}/${key}`; @@ -123,7 +118,6 @@ export class KvService { }; await this.setMetadataFile(Buffer.from(JSON.stringify(metadata))); this.logger.verbose(`Claimed namespace ${namespace}`); - this.kvProcessingQueue.add('namespaceModeration', metadata[namespace]); return metadata[namespace]; } diff --git a/src/main.ts b/src/main.ts index be6e863..e40a53d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,6 +3,7 @@ import { AppModule } from './app.module'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NestExpressApplication } from '@nestjs/platform-express'; import { join } from 'path'; +import * as cookieParser from 'cookie-parser'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -20,6 +21,7 @@ async function bootstrap() { app.enableCors({ origin: '*' }); app.useBodyParser('json', { limit: '50mb' }); app.useBodyParser('text', { limit: '50mb' }); + app.use(cookieParser()); app.disable('x-powered-by'); await app.listen(3000); } diff --git a/views/baby-names/form.hbs b/views/baby-names/form.hbs new file mode 100644 index 0000000..033883b --- /dev/null +++ b/views/baby-names/form.hbs @@ -0,0 +1,49 @@ +
+ {{#if message}} +
+ {{message}} +
+ {{/if}} +
+ + +
+
+

{{name}}

+
+ + +
+ +
+ + + + + + + + +
+ Hate it + Dislike it + Neutral + Like it + Love it +
+
+
+ +
\ No newline at end of file diff --git a/views/baby-names/index.hbs b/views/baby-names/index.hbs new file mode 100644 index 0000000..8a76c70 --- /dev/null +++ b/views/baby-names/index.hbs @@ -0,0 +1,13 @@ +
+ + +
+ +{{#if previousKey}} + or + +
+ + +
+{{/if}} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index ceeef4c..6cb07d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1061,6 +1061,11 @@ dependencies: "@types/node" "*" +"@types/cookie-parser@^1.4.9": + version "1.4.9" + resolved "https://registry.yarnpkg.com/@types/cookie-parser/-/cookie-parser-1.4.9.tgz#f0e79c766a58ee7369a52e7509b3840222f68ed2" + integrity sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g== + "@types/cookiejar@^2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" @@ -2345,6 +2350,14 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie-parser@^1.4.7: + version "1.4.7" + resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.7.tgz#e2125635dfd766888ffe90d60c286404fa0e7b26" + integrity sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw== + dependencies: + cookie "0.7.2" + cookie-signature "1.0.6" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -2355,6 +2368,11 @@ cookie@0.6.0: resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051" integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw== +cookie@0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + cookiejar@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"