Add initial baby-names service
This commit is contained in:
@@ -38,6 +38,7 @@
|
|||||||
"bull": "^4.11.5",
|
"bull": "^4.11.5",
|
||||||
"cache-manager": "^5.3.1",
|
"cache-manager": "^5.3.1",
|
||||||
"cache-manager-redis-yet": "^4.1.2",
|
"cache-manager-redis-yet": "^4.1.2",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"fp-ts": "^2.16.3",
|
"fp-ts": "^2.16.3",
|
||||||
"haversine-ts": "^1.2.0",
|
"haversine-ts": "^1.2.0",
|
||||||
"hbs": "^4.2.0",
|
"hbs": "^4.2.0",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"@nestjs/testing": "^10.0.0",
|
"@nestjs/testing": "^10.0.0",
|
||||||
"@types/axios": "^0.14.0",
|
"@types/axios": "^0.14.0",
|
||||||
"@types/bcrypt": "^5.0.0",
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jest": "^29.5.2",
|
"@types/jest": "^29.5.2",
|
||||||
"@types/multer": "^1.4.10",
|
"@types/multer": "^1.4.10",
|
||||||
|
@@ -33,6 +33,7 @@ import { HoardingModule } from './hoarding/hoarding.module';
|
|||||||
import { NamesModule } from './names/names.module';
|
import { NamesModule } from './names/names.module';
|
||||||
import { AssemblyAiModule } from './assembly-ai/assembly-ai.module';
|
import { AssemblyAiModule } from './assembly-ai/assembly-ai.module';
|
||||||
import { ClaudeModule } from './claude/claude.module';
|
import { ClaudeModule } from './claude/claude.module';
|
||||||
|
import { BabyNamesModule } from './baby-names/baby-names.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -112,6 +113,7 @@ import { ClaudeModule } from './claude/claude.module';
|
|||||||
NamesModule,
|
NamesModule,
|
||||||
AssemblyAiModule,
|
AssemblyAiModule,
|
||||||
ClaudeModule,
|
ClaudeModule,
|
||||||
|
BabyNamesModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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({
|
@Module({
|
||||||
providers: [KvService, MinioService],
|
providers: [KvService, MinioService],
|
||||||
imports: [
|
imports: [],
|
||||||
BullModule.registerQueue({
|
|
||||||
name: 'kv',
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
controllers: [KvController],
|
controllers: [KvController],
|
||||||
})
|
})
|
||||||
export class KvModule {}
|
export class KvModule {}
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
import { InjectQueue } from '@nestjs/bull';
|
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Queue } from 'bull';
|
|
||||||
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
|
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
|
||||||
import { MinioService } from 'src/minio/minio.service';
|
import { MinioService } from 'src/minio/minio.service';
|
||||||
|
|
||||||
@@ -12,10 +10,7 @@ export class KvService {
|
|||||||
private readonly kvMetadataPath = `${this.kvPrefix}/${this.kvMetadataFileName}`;
|
private readonly kvMetadataPath = `${this.kvPrefix}/${this.kvMetadataFileName}`;
|
||||||
private readonly logger: Logger = new Logger(KvService.name);
|
private readonly logger: Logger = new Logger(KvService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly minioService: MinioService) {}
|
||||||
private readonly minioService: MinioService,
|
|
||||||
@InjectQueue('kv') private kvProcessingQueue: Queue,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public generateFilePath(namespace: string, key: string): string {
|
public generateFilePath(namespace: string, key: string): string {
|
||||||
return `${this.kvPrefix}/${namespace}/${key}`;
|
return `${this.kvPrefix}/${namespace}/${key}`;
|
||||||
@@ -123,7 +118,6 @@ export class KvService {
|
|||||||
};
|
};
|
||||||
await this.setMetadataFile(Buffer.from(JSON.stringify(metadata)));
|
await this.setMetadataFile(Buffer.from(JSON.stringify(metadata)));
|
||||||
this.logger.verbose(`Claimed namespace ${namespace}`);
|
this.logger.verbose(`Claimed namespace ${namespace}`);
|
||||||
this.kvProcessingQueue.add('namespaceModeration', metadata[namespace]);
|
|
||||||
return metadata[namespace];
|
return metadata[namespace];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ import { AppModule } from './app.module';
|
|||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
import * as cookieParser from 'cookie-parser';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
@@ -20,6 +21,7 @@ async function bootstrap() {
|
|||||||
app.enableCors({ origin: '*' });
|
app.enableCors({ origin: '*' });
|
||||||
app.useBodyParser('json', { limit: '50mb' });
|
app.useBodyParser('json', { limit: '50mb' });
|
||||||
app.useBodyParser('text', { limit: '50mb' });
|
app.useBodyParser('text', { limit: '50mb' });
|
||||||
|
app.use(cookieParser());
|
||||||
app.disable('x-powered-by');
|
app.disable('x-powered-by');
|
||||||
await app.listen(3000);
|
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:
|
dependencies:
|
||||||
"@types/node" "*"
|
"@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":
|
"@types/cookiejar@^2.1.5":
|
||||||
version "2.1.5"
|
version "2.1.5"
|
||||||
resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78"
|
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"
|
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a"
|
||||||
integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==
|
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:
|
cookie-signature@1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
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"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.6.0.tgz#2798b04b071b0ecbff0dbb62a505a8efa4e19051"
|
||||||
integrity sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==
|
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:
|
cookiejar@^2.1.4:
|
||||||
version "2.1.4"
|
version "2.1.4"
|
||||||
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
|
resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b"
|
||||||
|
Reference in New Issue
Block a user