Add initial baby-names service
This commit is contained in:
@@ -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",
|
||||
|
@@ -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: [
|
||||
|
80
src/baby-names/baby-names.controller.ts
Normal file
80
src/baby-names/baby-names.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
13
src/baby-names/baby-names.module.ts
Normal file
13
src/baby-names/baby-names.module.ts
Normal 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 {}
|
100
src/baby-names/baby-names.service.ts
Normal file
100
src/baby-names/baby-names.service.ts
Normal 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);
|
||||
}
|
||||
}
|
@@ -6,11 +6,7 @@ import { BullModule } from '@nestjs/bull';
|
||||
|
||||
@Module({
|
||||
providers: [KvService, MinioService],
|
||||
imports: [
|
||||
BullModule.registerQueue({
|
||||
name: 'kv',
|
||||
}),
|
||||
],
|
||||
imports: [],
|
||||
controllers: [KvController],
|
||||
})
|
||||
export class KvModule {}
|
||||
|
@@ -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];
|
||||
}
|
||||
|
||||
|
@@ -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
49
views/baby-names/form.hbs
Normal 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>
|
13
views/baby-names/index.hbs
Normal file
13
views/baby-names/index.hbs
Normal 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}}
|
18
yarn.lock
18
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"
|
||||
|
Reference in New Issue
Block a user