Add initial baby-names service

This commit is contained in:
2025-06-17 17:17:54 -06:00
parent a4c75cfbd2
commit 5a7bad327f
11 changed files with 281 additions and 12 deletions

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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,
};
}
}

View File

@@ -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 {}

View File

@@ -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<number> {
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<void> {
await this.minioService.uploadBuffer(
this.minioService.defaultBucketName,
`baby-names/${userKey}-current`,
Buffer.from(number.toString()),
);
}
public async getUserScores(userKey: string): Promise<NameCountMap> {
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<void> {
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<void> {
const scores = await this.getUserScores(userKey);
scores[name] = score;
await this.saveUserScores(userKey, scores);
}
}

View File

@@ -6,11 +6,7 @@ import { BullModule } from '@nestjs/bull';
@Module({
providers: [KvService, MinioService],
imports: [
BullModule.registerQueue({
name: 'kv',
}),
],
imports: [],
controllers: [KvController],
})
export class KvModule {}

View File

@@ -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];
}

View File

@@ -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<NestExpressApplication>(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);
}

49
views/baby-names/form.hbs Normal file
View File

@@ -0,0 +1,49 @@
<form action='/baby-names' method='post'>
{{#if message}}
<div>
<i>{{message}}</i>
</div>
{{/if}}
<div>
<label for='key'>Your key:</label>
<input type='text' name='key' value='{{key}}' required />
</div>
<div>
<h1>{{name}}</h1>
</div>
<input type='hidden' name='name' value='{{name}}' />
<input type='hidden' name='nameindex' value='{{index}}' />
<div>
<label for='opinion'>How do you feel about this name?</label>
<div style='width: 300px;'>
<input
type='range'
id='opinion'
name='opinion'
min='-2'
max='2'
step='1'
list='opinion-ticks'
style='width: 100%;'
required
/>
<datalist id='opinion-ticks'>
<option value='-2' label='Hate it'></option>
<option value='-1' label='Dislike it'></option>
<option value='0' label='Neutral'></option>
<option value='1' label='Like it'></option>
<option value='2' label='Love it'></option>
</datalist>
<div
style='display: flex; justify-content: space-between; font-size: 0.9em; margin-top: 0.2em;'
>
<span>Hate it</span>
<span>Dislike it</span>
<span>Neutral</span>
<span>Like it</span>
<span>Love it</span>
</div>
</div>
</div>
<button type='submit'>Submit Opinion</button>
</form>

View File

@@ -0,0 +1,13 @@
<form action='/baby-names/form' method='get'>
<input type='text' name='key' placeholder='Enter your key' required />
<button type='submit'>Log In</button>
</form>
{{#if previousKey}}
<i>or</i>
<form action='/baby-names/form' method='get'>
<input type='hidden' name='key' value='{{previousKey}}' />
<button type='submit'>Continue as {{previousKey}}</button>
</form>
{{/if}}

View File

@@ -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"