Compare commits

...

64 Commits

Author SHA1 Message Date
bb15dc69d3 Merge pull request 'fix(review): fixed TanninString enum' () from tasting-notes into staging
Reviewed-on: 
2025-04-17 11:09:47 +00:00
nostrdev-com
c21577c77a fix(review): fixed TanninString enum 2025-04-17 14:08:29 +03:00
d7e008f308 Merge pull request 'tasting-notes' () from tasting-notes into staging
Reviewed-on: 
2025-04-17 10:59:51 +00:00
nostrdev-com
c94a3cb83a fix(spirit): fixed characteristic validation 2025-04-17 13:50:05 +03:00
nostrdev-com
7b17669c6d fix(spirit): fixed characteristic validation 2025-04-17 13:49:45 +03:00
nostrdev-com
a374610073 feat(review): improved tasting note validation 2025-04-17 13:48:15 +03:00
5136cb2a74 Merge pull request 'tasting-notes' () from tasting-notes into staging
Reviewed-on: 
2025-04-16 09:23:00 +00:00
nostrdev-com
529838406f feat(review): added tasting note validation 2025-04-16 12:19:45 +03:00
nostrdev-com
4a7b9f194c chore: fixed types naming 2025-04-14 12:45:59 +03:00
079b5958ef Merge pull request 'feat(sake): added yeast and koji validations' () from sake-validation into staging
Reviewed-on: 
2025-04-14 08:24:25 +00:00
nostrdev-com
ed9d40e409 feat(sake): added yeast and koji validations 2025-04-14 11:22:29 +03:00
d9e3cd188e Merge pull request 'feat(sake): added sake validation' () from spirit-validation into staging
Reviewed-on: 
2025-04-11 14:50:23 +00:00
nostrdev-com
f1ca771574 feat(sake): added sake validation 2025-04-11 17:48:23 +03:00
363c119704 Merge pull request 'fix(wine): removed varietal validation' () from spirit-validation into staging
Reviewed-on: 
2025-04-11 12:19:51 +00:00
nostrdev-com
f6a09c1647 fix(wine): removed varietal validation 2025-04-11 15:16:10 +03:00
88814b9f22 Merge pull request 'spirit-validation' () from spirit-validation into staging
Reviewed-on: 
2025-04-11 11:16:44 +00:00
nostrdev-com
39f615ebb2 feat(wine): added grapeVarietal 2025-04-11 14:13:11 +03:00
nostrdev-com
6526935620 chore: improved validations 2025-04-11 11:56:56 +03:00
613c298410 Merge pull request 'chore(coffee): updated CoffeeRoast type' () from spirits-characteristics into staging
Reviewed-on: 
2025-04-11 08:01:30 +00:00
nostrdev-com
c5949010ad chore(coffee): updated CoffeeRoast type 2025-04-11 10:59:31 +03:00
d1857a3ccd Merge pull request 'feat(spirit): added arrack and aquavit' () from spirits-characteristics into staging
Reviewed-on: 
2025-04-11 07:04:42 +00:00
nostrdev-com
abfedf7e32 feat(spirit): added arrack and aquavit 2025-04-11 10:02:56 +03:00
2a743043e0 Merge pull request 'feat: added spirit validation' () from spirits-characteristics into staging
Reviewed-on: 
2025-04-10 15:32:40 +00:00
nostrdev-com
50b2999c66 feat: added spirit validation 2025-04-10 18:29:46 +03:00
3816e32289 Merge pull request 'fix(wine): fixed region validation' () from wine-characteristics into staging
Reviewed-on: 
2025-04-09 13:28:49 +00:00
nostrdev-com
c414e0ff3e fix(wine): fixed region validation 2025-04-09 16:27:06 +03:00
1598ee8b0b Merge pull request 'wine-characteristics' () from wine-characteristics into staging
Reviewed-on: 
2025-04-09 12:46:00 +00:00
nostrdev-com
9cc8ba422c chore(types): improved cases 2025-04-09 15:41:17 +03:00
nostrdev-com
aa82179788 feat(review): improved rating validation 2025-04-09 15:39:43 +03:00
nostrdev-com
5e01078d7a feat(wine): implemented characteristics validation 2025-04-09 15:29:31 +03:00
f44aa5673e Merge pull request 'chore: fixed a typo' () from payload-validation into staging
Reviewed-on: 
2025-04-07 05:42:20 +00:00
nostrdev-com
8985643bda chore: fixed a typo 2025-04-07 08:40:11 +03:00
5484a8d9e9 Merge pull request 'fix(wine): fix wine validation' () from payload-validation into staging
Reviewed-on: 
2025-04-04 14:15:01 +00:00
nostrdev-com
5154b3c927 fix(wine): fix wine validation 2025-04-04 17:13:18 +03:00
dc6e444c79 Merge pull request 'chore: improved alcoholToStandardDrinks utility' () from payload-validation into staging
Reviewed-on: 
2025-04-04 13:56:44 +00:00
nostrdev-com
b2c2d9e470 chore: improved alcoholToStandardDrinks utility 2025-04-04 16:54:09 +03:00
d5a5488b9d Merge pull request 'payload-validation' () from payload-validation into staging
Reviewed-on: 
2025-04-04 13:49:39 +00:00
nostrdev-com
b02d4889ff feat(wine): added "volume" to the wine model and improved standard drinks logic 2025-04-04 16:41:45 +03:00
nostrdev-com
4fb326630c feat(wine): implemented region validation 2025-04-04 16:18:11 +03:00
a067398825 Merge pull request 'payload-validation' () from payload-validation into staging
Reviewed-on: 
2025-04-03 08:01:56 +00:00
nostrdev-com
6b0730fd3d chore: fixed vintage type 2025-04-03 10:58:00 +03:00
nostrdev-com
11776c4f5c chore(ci): added staging-pull-request workflow 2025-04-03 10:54:17 +03:00
nostrdev-com
87c510b986 chore: fixing pre-commit git hook 2025-04-03 10:48:57 +03:00
nostrdev-com
990b81abe7 chore(scripts): added preinstall script 2025-04-03 10:43:57 +03:00
nostrdev-com
c274d0b3cd chore: fixing pre-commit git hook 2025-04-03 10:32:49 +03:00
nostrdev-com
862e3b4618 chore: fixing pre-commit git hook 2025-04-03 10:31:29 +03:00
ff9516ed66 Merge pull request 'feat(wine): added payload validation' () from payload-validation into staging
Reviewed-on: 
2025-04-03 07:18:02 +00:00
nostrdev-com
b288e40d13 feat(wine): added payload validation 2025-04-03 10:16:55 +03:00
0371fb55da Merge pull request 'fix(user): disabled multiple npubs' () from payload-validation into staging
Reviewed-on: 
2025-04-02 12:12:01 +00:00
nostrdev-com
386ff5b8e5 fix(user): disabled multiple npubs 2025-04-02 15:10:22 +03:00
069a708ac3 Merge pull request 'payload-validation' () from payload-validation into staging
Reviewed-on: 
2025-04-02 09:47:26 +00:00
nostrdev-com
14b61fa0f3 chore: fixed variable name 2025-04-02 12:46:36 +03:00
nostrdev-com
35b2cec312 feat(review): added review validation 2025-04-02 12:45:17 +03:00
063f945cf0 Merge pull request 'feat(nostr): added nostrEvent validation' () from payload-validation into staging
Reviewed-on: 
2025-04-02 08:10:40 +00:00
nostrdev-com
ce9306271d feat(nostr): added nostrEvent validation 2025-04-02 11:09:31 +03:00
922d013325 Merge pull request 'feat(users): added validation to the POST route' () from payload-validation into staging
Reviewed-on: 
2025-04-02 07:13:13 +00:00
nostrdev-com
2c5037a491 feat(users): added validation to the POST route 2025-04-02 10:11:37 +03:00
5ecbe73cd7 Merge pull request 'chore: improved data models' () from schemas into staging
Reviewed-on: 
2025-04-01 13:10:25 +00:00
638dc9cd42 Merge pull request 'feat: added coffee model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 14:02:33 +00:00
05d0709f49 Merge pull request 'feat: added spirit model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 13:32:57 +00:00
19189c2866 Merge pull request 'feat: added sake model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 13:00:06 +00:00
eb0f389e07 Merge pull request 'schemas' () from schemas into staging
Reviewed-on: 
2025-03-31 12:25:49 +00:00
bf0e15164a Merge pull request 'chore: changed review route to reviews' () from schemas into staging
Reviewed-on: 
2025-03-27 15:05:00 +00:00
35c2debfae Merge pull request 'feat: added nostr and review routes' () from schemas into staging
Reviewed-on: 
2025-03-27 14:44:11 +00:00
52 changed files with 4644 additions and 400 deletions

@ -1,5 +1,7 @@
#!/bin/sh
echo "*****pre-commit hook******"
# Avoid commits to the master branch
BRANCH=`git rev-parse --abbrev-ref HEAD`
REGEX="^(master|main|staging|development)$"

@ -0,0 +1,37 @@
name: Open PR on Staging
on:
pull_request:
types: [opened, edited, synchronize]
branches:
- staging
jobs:
audit_and_check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v2
with:
node-version: 20
- name: Audit
run: npm audit --omit=dev
- name: Install Dependencies
run: npm ci
- name: License check
run: npm run license-checker
- name: Lint check
run: npm run lint
- name: Formatter check
run: npm run formatter:check
- name: Create Build
run: npm run build

@ -11,7 +11,10 @@
"Blanco",
"Cachaça",
"Caturra",
"colour",
"Colours",
"Coren",
"EAN",
"espadín",
"Genever",
"Jägermeister",
@ -42,6 +45,7 @@
"Robusta",
"RRP",
"screwcap",
"SKU",
"Soju",
"Sokujō",
"Solera",
@ -50,6 +54,8 @@
"Tequilana",
"tobalá",
"Typica",
"Umami",
"UPC",
"Verte",
"VSOP",
"Yamahai",

183
package-lock.json generated

@ -13,7 +13,9 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"i18n-iso-countries": "^7.14.0",
"mongodb": "^6.15.0"
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
@ -301,6 +303,21 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
"license": "BSD-3-Clause"
},
"node_modules/@hapi/topo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -404,6 +421,51 @@
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@noble/ciphers": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz",
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
"integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.2"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
"integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -702,6 +764,57 @@
"dev": true,
"license": "MIT"
},
"node_modules/@scure/base": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
"integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
"funding": [
{
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
],
"license": "MIT"
},
"node_modules/@scure/bip32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"license": "MIT",
"dependencies": {
"@noble/curves": "~1.1.0",
"@noble/hashes": "~1.3.1",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip32/node_modules/@noble/curves": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.3.1"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "~1.3.0",
"@scure/base": "~1.1.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@sec-ant/readable-stream": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
@ -1435,6 +1548,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@sideway/formula": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==",
"license": "BSD-3-Clause"
},
"node_modules/@sideway/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
"license": "BSD-3-Clause"
},
"node_modules/@sindresorhus/is": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-2.1.1.tgz",
@ -4947,6 +5081,19 @@
"node": ">= 0.6.0"
}
},
"node_modules/joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
"@sideway/address": "^4.1.5",
"@sideway/formula": "^3.0.1",
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -6262,6 +6409,38 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/nostr-tools": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.11.1.tgz",
"integrity": "sha512-+Oj5t+behIkU9kh3go5wg8Aa5oR7euBU9gOItUNapJe5Gaa+KPzMuTIN+rMRK3DaZ4Zt6RM4kR/ddwstzGKf7g==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "^0.5.1",
"@noble/curves": "1.2.0",
"@noble/hashes": "1.3.1",
"@scure/base": "1.1.1",
"@scure/bip32": "1.3.1",
"@scure/bip39": "1.2.1"
},
"optionalDependencies": {
"nostr-wasm": "0.1.0"
},
"peerDependencies": {
"typescript": ">=5.0.0"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/nostr-wasm": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
"integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
"license": "MIT",
"optional": true
},
"node_modules/npm": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/npm/-/npm-10.9.2.tgz",
@ -11277,7 +11456,7 @@
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz",
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

@ -16,7 +16,8 @@
"lint:fix": "eslint . --fix --ext ts --report-unused-disable-directives --max-warnings 0",
"lint:staged": "eslint --fix --ext ts --report-unused-disable-directives --max-warnings 0",
"lint-staged": "lint-staged",
"start:db": "docker compose -f mongo-docker-compose.yml up -d"
"start:db": "docker compose -f mongo-docker-compose.yml up -d",
"preinstall": "git config core.hooksPath .git-hooks"
},
"repository": {
"type": "git",
@ -30,7 +31,9 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"i18n-iso-countries": "^7.14.0",
"mongodb": "^6.15.0"
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",

@ -10,7 +10,7 @@ import {
spiritsRouter,
coffeeRouter
} from './routes'
import { Routes } from './types'
import { Route } from './types'
dotenv.config()
@ -19,13 +19,13 @@ const port = process.env.PORT || 3000
connectToDatabase()
.then(() => {
app.use(Routes.Users, usersRouter)
app.use(Routes.NostrEvents, nostrRouter)
app.use(Routes.Reviews, reviewsRouter)
app.use(Routes.Wines, winesRouter)
app.use(Routes.Sake, sakeRouter)
app.use(Routes.Spirits, spiritsRouter)
app.use(Routes.Coffee, coffeeRouter)
app.use(Route.Users, usersRouter)
app.use(Route.NostrEvents, nostrRouter)
app.use(Route.Reviews, reviewsRouter)
app.use(Route.Wines, winesRouter)
app.use(Route.Sake, sakeRouter)
app.use(Route.Spirits, spiritsRouter)
app.use(Route.Coffee, coffeeRouter)
app.listen(port, () => {
console.log(`Server started at http://localhost:${port}`)

@ -1,10 +1,12 @@
import { ObjectId } from 'mongodb'
export class NostrEvent {
constructor(
public nostrId: string, // nostr unique identifier
public pubkey: string, // public key of the event creator
public created_at: number, // timestamp
public kind: number, // event type, e.g., review, article, comment
public tags: [string][], // array of keywords or hashtags
public content: string // text content of the event
public tags: string[][], // array of keywords or hashtags
public content: string, // text content of the event
public id?: ObjectId // database object id
) {}
}

@ -1,12 +1,14 @@
import { ObjectId } from 'mongodb'
import { ProductType, RatingOption, TastingNote } from '../types'
export class Review {
constructor(
public eventId: string, // foreign key referencing the nostrEvents collection
public productId: string, // unique identifier for the product
public rating: number, // numerical rating, e.g., 1-100
public productType: ProductType, // product type
public rating: number | RatingOption, // numerical rating, e.g., 84-100 or NS (no score)
public reviewText: string, // text content of the review
public tastingNotes: string[], // array of tasting notes, e.g., flavours, aromas
public tastingNote: TastingNote, // an object representing tasting notes
public id?: ObjectId // database object id
) {}
}

@ -1,5 +1,15 @@
import { ObjectId } from 'mongodb'
import { SakeDesignation, SakeStarter, Vintage } from '../types'
import {
SakeDesignation,
SakeStarter,
VintageOption,
SakeCharacteristic,
StandardDrink,
SakeVolume,
RiceVarietal,
SakeYeastStrain,
SakeKoji
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -12,13 +22,17 @@ export class Sake {
public region: string, // appellation, village, sub-region, vineyard
public name: string, // label
public producerId: ObjectId, // product producer
public designation: SakeDesignation, // table, pure, blended, mirin: new/true/salt
public designation: SakeDesignation, // table, pure, blended
public polishRate: number, // %
public characteristic: SakeCharacteristic,
public starter: SakeStarter, // sake starter
public yeastStrain: number,
public yeastStrain: SakeYeastStrain,
public volume: SakeVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle per 100ml
public vintage: Vintage, // year, nv (non-vintage) or mv (multi-vintage)
public standardDrinks: StandardDrink, // number representing an amount of standard drinks per bottle per 100ml
public riceVarietal: RiceVarietal[], // if more than one, list as 'blend'
public koji: SakeKoji, // if more than one, list as 'blend'
public vintage: number | VintageOption, // year, nv (non-vintage) or mv (multi-vintage)
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product

@ -1,5 +1,13 @@
import { ObjectId } from 'mongodb'
import { SpiritType, SpiritVariant, Ingredient, Vintage } from '../types'
import {
SpiritType,
SpiritVariant,
Ingredient,
VintageOption,
StandardDrink,
SpiritVolume,
SpiritCharacteristic
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -14,10 +22,12 @@ export class Spirit {
public producerId: ObjectId, // product producer
public type: SpiritType, // spirit type
public variant: SpiritVariant, // vodka, rum, liqueur cream, etc
public characteristic: SpiritCharacteristic, // light aromatic, textural, fruit forward, structural & savoury, powerful
public ingredients: Ingredient[], // an array of ingredients(flavouring)
public volume: SpiritVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle
public vintage: Vintage, // year, nv (non-vintage) or mv (multi-vintage)
public vintage: number | VintageOption, // year, nv (non-vintage) or mv (multi-vintage)
public standardDrinks: StandardDrink, // an amount of standard drinks per 100ml and bottle in AU, UK and US
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product

@ -3,9 +3,9 @@ import { UserRole } from '../types'
export class User {
constructor(
public name: string,
public npub: string[],
public role: UserRole,
public id?: ObjectId
public name: string, // name
public npub: string, // npub
public role: UserRole, // user role (user, reviewer, producer)
public id?: ObjectId // database object id
) {}
}

@ -1,5 +1,19 @@
import { ObjectId } from 'mongodb'
import { WineType, Viticulture, BottleClosure, Vintage } from '../types'
import {
WineType,
Viticulture,
BottleClosure,
VintageOption,
StandardDrink,
WineRegion,
WineVolume,
WineStyle,
WhiteWineCharacteristic,
AmberWineCharacteristic,
RoseWineCharacteristic,
RedWineCharacteristic,
GrapeVarietal
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -9,21 +23,27 @@ export class Wine {
public productCodeUPC: string, // Product Code (https://en.wikipedia.org/wiki/Universal_Product_Code)
public productCodeSKU: string, // Stock keeping unit (https://en.wikipedia.org/wiki/Stock_keeping_unit)
public type: WineType, // numerical rating, e.g., 1-100
public style: string, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: string, // light aromatic, textural, fruit forward, structural & savoury, powerful
public style: WineStyle, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: (
| WhiteWineCharacteristic
| AmberWineCharacteristic
| RoseWineCharacteristic
| RedWineCharacteristic
)[], // light aromatic, textural, fruit forward, structural & savoury, powerful
public country: Alpha2Code, // two-letter country codes defined in ISO 3166-1 (https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
public region: string, // appellation, village, sub-region, vineyard
public region: WineRegion, // appellation, village, sub-region, vineyard
public name: string, // label
public producerId: ObjectId, // product producer
public varietal: string, // if more than one, list as 'blend'
public vintage: Vintage, // year, nv (non-vintage) or mv (multi-vintage)
public grapeVarietal: GrapeVarietal[], // if more than one, list as 'blend'
public vintage: number | VintageOption, // year, nv (non-vintage) or mv (multi-vintage)
public volume: WineVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle
public viticulture: Viticulture, // two-letter country codes
public standardDrinks: StandardDrink, // an amount of standard drinks per 100ml and bottle in AU, UK and US
public viticulture: Viticulture, // viticulture
public sulfites: number, // parts per million
public filtered: boolean, // is wine filtered (fined (egg or fish))
public vegan: boolean,
public kosher: boolean,
public vegan: boolean, // if wine is vegan
public kosher: boolean, // if wine is kosher
public closure: BottleClosure, // cork, crown-seal, screwcap
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD

@ -2,7 +2,6 @@ import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Coffee } from '../models'
// Global Config
export const coffeeRouter = express.Router()
coffeeRouter.use(express.json())

@ -1,9 +1,14 @@
// External Dependencies
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { NostrEvent } from '../models'
import {
nostrEventValidation,
handleReqError,
handleReqSuccess
} from '../utils'
import { Event } from 'nostr-tools'
import Joi from 'joi'
// Global Config
export const nostrRouter = express.Router()
nostrRouter.use(express.json())
@ -26,23 +31,39 @@ nostrRouter.get('/', async (_req: Request, res: Response) => {
// POST
nostrRouter.post('/', async (req: Request, res: Response) => {
try {
const nostrEvent = req.body as NostrEvent
const {
error,
value: event
}: { error: Joi.ValidationError | undefined; value: Event } =
nostrEventValidation(req.body)
if (error) {
throw error.details[0].message
}
const { id, pubkey, created_at, kind, tags, content } = event
const existingEvent = await collections.nostrEvents?.findOne({
nostrId: id
})
if (existingEvent) {
throw new Error('nostr event with provided "id" exists')
}
const nostrEvent: NostrEvent = {
nostrId: id,
pubkey,
created_at,
kind,
tags,
content
}
const result = await collections.nostrEvents?.insertOne(nostrEvent)
if (result) {
res
.status(201)
.send(
`Successfully created a new nostrEvent with id ${result.insertedId}`
)
} else {
res.status(500).send('Failed to create a new nostrEvent.')
}
handleReqSuccess(res, result, 'nostrEvent')
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,8 +1,11 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Review } from '../models'
import { Coffee, Review, Sake, Spirit, Wine } from '../models'
import { reviewValidation, handleReqError, handleReqSuccess } from '../utils'
import Joi from 'joi'
import { DBcollection, TastingNoteKey } from '../types'
import { BSON } from 'mongodb'
// Global Config
export const reviewsRouter = express.Router()
reviewsRouter.use(express.json())
@ -10,7 +13,7 @@ reviewsRouter.use(express.json())
// GET
reviewsRouter.get('/', async (_req: Request, res: Response) => {
try {
const reviews = await collections.reviews?.find({}).toArray()
const reviews = await collections[DBcollection.Reviews]?.find({}).toArray()
res.status(200).send(reviews)
} catch (error: unknown) {
@ -25,22 +28,71 @@ reviewsRouter.get('/', async (_req: Request, res: Response) => {
// POST
reviewsRouter.post('/', async (req: Request, res: Response) => {
try {
const review = req.body as Review
const {
error,
value: review
}: { error: Joi.ValidationError | undefined; value: Review } =
reviewValidation(req.body)
const result = await collections.reviews?.insertOne(review)
if (result) {
res
.status(201)
.send(`Successfully created a new review with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new review.')
if (error) {
throw error.details[0].message
}
const existingReview = await collections[DBcollection.Reviews]?.findOne({
eventId: review.eventId
})
if (existingReview) {
throw new Error('review with provided "eventId" exists')
}
const { productId } = review
const _id = new BSON.ObjectId(productId)
let product: Wine | Spirit | Sake | null | undefined = await collections[
DBcollection.Wines
]?.findOne<Wine>({
_id
})
if (!product) {
product = await collections[DBcollection.Sake]?.findOne<Sake>({
_id
})
if (!product) {
product = await collections[DBcollection.Spirits]?.findOne<Spirit>({
_id
})
if (!product) {
const coffee = await collections[
DBcollection.Coffee
]?.findOne<Coffee>({
_id
})
if (!coffee) {
throw new Error('product with provided "productId" does not exists')
}
}
}
}
// Add age property to tasting note, if product has vintage property
if (product) {
const { vintage } = product
if (typeof vintage === 'number') {
review.tastingNote[TastingNoteKey.TextureAndBalance].age =
new Date().getFullYear() - vintage
}
}
const result = await collections[DBcollection.Reviews]?.insertOne(review)
handleReqSuccess(res, result, 'review')
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,8 +1,17 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Sake } from '../models'
import {
sakeValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
alcoholToStandardDrinks,
volumeToMl
} from '../utils'
import Joi from 'joi'
import { DBcollection, ProductType } from '../types'
// Global Config
export const sakeRouter = express.Router()
sakeRouter.use(express.json())
@ -25,22 +34,36 @@ sakeRouter.get('/', async (_req: Request, res: Response) => {
// POST
sakeRouter.post('/', async (req: Request, res: Response) => {
try {
const sake = req.body as Sake
const {
error,
value: sake
}: { error: Joi.ValidationError | undefined; value: Sake } = sakeValidation(
req.body
)
const result = await collections.sake?.insertOne(sake)
if (result) {
res
.status(201)
.send(`Successfully created a new sake with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new sake.')
if (error) {
throw error.details[0].message
}
const { productCodeEAN, productCodeUPC, productCodeSKU } = sake
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
DBcollection.Sake,
ProductType.Sake
)
sake.standardDrinks = alcoholToStandardDrinks(
sake.alcohol,
volumeToMl(sake.volume)
)
const result = await collections[DBcollection.Sake]?.insertOne(sake)
handleReqSuccess(res, result, ProductType.Spirit)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,8 +1,17 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Spirit } from '../models'
import {
spiritValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
alcoholToStandardDrinks,
volumeToMl
} from '../utils'
import Joi from 'joi'
import { DBcollection, ProductType } from '../types'
// Global Config
export const spiritsRouter = express.Router()
spiritsRouter.use(express.json())
@ -25,22 +34,35 @@ spiritsRouter.get('/', async (_req: Request, res: Response) => {
// POST
spiritsRouter.post('/', async (req: Request, res: Response) => {
try {
const spirit = req.body as Spirit
const {
error,
value: spirit
}: { error: Joi.ValidationError | undefined; value: Spirit } =
spiritValidation(req.body)
const result = await collections.spirits?.insertOne(spirit)
if (result) {
res
.status(201)
.send(`Successfully created a new spirit with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new spirit.')
if (error) {
throw error.details[0].message
}
const { productCodeEAN, productCodeUPC, productCodeSKU } = spirit
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
DBcollection.Spirits,
ProductType.Spirit
)
spirit.standardDrinks = alcoholToStandardDrinks(
spirit.alcohol,
volumeToMl(spirit.volume)
)
const result = await collections[DBcollection.Spirits]?.insertOne(spirit)
handleReqSuccess(res, result, ProductType.Spirit)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,9 +1,9 @@
// External Dependencies
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { User } from '../models'
import { userValidation, handleReqError, handleReqSuccess } from '../utils'
import Joi from 'joi'
// Global Config
export const usersRouter = express.Router()
usersRouter.use(express.json())
@ -26,21 +26,29 @@ usersRouter.get('/', async (_req: Request, res: Response) => {
// POST
usersRouter.post('/', async (req: Request, res: Response) => {
try {
const newUser = req.body as User
const {
error,
value: newUser
}: { error: Joi.ValidationError | undefined; value: User } = userValidation(
req.body
)
if (error) {
throw error.details[0].message
}
const existingUser = await collections.users?.findOne({
npub: newUser.npub
})
if (existingUser) {
throw new Error('user with provided "npub" exists')
}
const result = await collections.users?.insertOne(newUser)
if (result) {
res
.status(201)
.send(`Successfully created a new user with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new user.')
}
handleReqSuccess(res, result, 'user')
} catch (error) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,8 +1,17 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Wine } from '../models'
import {
wineValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
alcoholToStandardDrinks,
volumeToMl
} from '../utils'
import Joi from 'joi'
import { DBcollection, ProductType } from '../types'
// Global Config
export const winesRouter = express.Router()
winesRouter.use(express.json())
@ -10,7 +19,7 @@ winesRouter.use(express.json())
// GET
winesRouter.get('/', async (_req: Request, res: Response) => {
try {
const wines = await collections.wines?.find({}).toArray()
const wines = await collections[DBcollection.Wines]?.find({}).toArray()
res.status(200).send(wines)
} catch (error: unknown) {
@ -25,22 +34,36 @@ winesRouter.get('/', async (_req: Request, res: Response) => {
// POST
winesRouter.post('/', async (req: Request, res: Response) => {
try {
const wine = req.body as Wine
const {
error,
value: wine
}: { error: Joi.ValidationError | undefined; value: Wine } = wineValidation(
req.body
)
const result = await collections.wines?.insertOne(wine)
if (result) {
res
.status(201)
.send(`Successfully created a new wine with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new wine.')
if (error) {
throw error.details[0].message
}
const { productCodeEAN, productCodeUPC, productCodeSKU } = wine
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
DBcollection.Wines,
ProductType.Wine
)
wine.standardDrinks = alcoholToStandardDrinks(
wine.alcohol,
volumeToMl(wine.volume)
)
const result = await collections[DBcollection.Wines]?.insertOne(wine)
handleReqSuccess(res, result, ProductType.Wine)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
handleReqError(res, error)
}
})

@ -1,16 +1,16 @@
import * as mongoDB from 'mongodb'
import * as dotenv from 'dotenv'
import { DBcollections } from '../types'
import { DBcollection } from '../types'
// Global Variables
export const collections: {
[DBcollections.Users]?: mongoDB.Collection
[DBcollections.NostrEvents]?: mongoDB.Collection
[DBcollections.Reviews]?: mongoDB.Collection
[DBcollections.Wines]?: mongoDB.Collection
[DBcollections.Sake]?: mongoDB.Collection
[DBcollections.Spirits]?: mongoDB.Collection
[DBcollections.Coffee]?: mongoDB.Collection
[DBcollection.Users]?: mongoDB.Collection
[DBcollection.NostrEvents]?: mongoDB.Collection
[DBcollection.Reviews]?: mongoDB.Collection
[DBcollection.Wines]?: mongoDB.Collection
[DBcollection.Sake]?: mongoDB.Collection
[DBcollection.Spirits]?: mongoDB.Collection
[DBcollection.Coffee]?: mongoDB.Collection
} = {}
// Initialize Connection
@ -29,20 +29,20 @@ export async function connectToDatabase() {
const db: mongoDB.Db = client.db(process.env.DB_NAME)
const usersCollection: mongoDB.Collection = db.collection(DBcollections.Users)
const usersCollection: mongoDB.Collection = db.collection(DBcollection.Users)
const nostrEventsCollection: mongoDB.Collection = db.collection(
DBcollections.NostrEvents
DBcollection.NostrEvents
)
const reviewsCollection: mongoDB.Collection = db.collection(
DBcollections.Reviews
DBcollection.Reviews
)
const winesCollection: mongoDB.Collection = db.collection(DBcollections.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollections.Sake)
const winesCollection: mongoDB.Collection = db.collection(DBcollection.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollection.Sake)
const spiritsCollection: mongoDB.Collection = db.collection(
DBcollections.Spirits
DBcollection.Spirits
)
const coffeeCollection: mongoDB.Collection = db.collection(
DBcollections.Coffee
DBcollection.Coffee
)
collections.users = usersCollection

@ -1,10 +1,10 @@
export type CoffeeProcessingType =
| 'de-caff'
| 'honey'
| 'semi-dry'
| 'swiss water'
| 'sundried'
| 'washed'
| 'De-caff'
| 'Honey'
| 'Semi-dry'
| 'Swiss water'
| 'Sundried'
| 'Washed'
type CoffeeVarietyType = 'Robusta' | 'Arabica'
@ -31,6 +31,7 @@ export type CoffeeVariety = {
}
export type CoffeeRoast =
| 'Green'
| 'Light'
| 'Medium'
| 'Medium-Dark'

@ -1,4 +1,4 @@
export enum DBcollections {
export enum DBcollection {
Users = 'users',
NostrEvents = 'nostrEvents',
Reviews = 'reviews',

@ -6,3 +6,5 @@ export * from './wine'
export * from './sake'
export * from './spirit'
export * from './coffee'
export * from './review'
export * from './review/'

@ -1,64 +1,79 @@
export type Availability = 'in stock' | 'out of stock' | 'discontinued'
export type Availability = 'In stock' | 'Out of stock' | 'Discontinued'
export type Ingredient =
| 'Blanche'
| 'Anise'
| 'Fennel'
| 'Hyssop'
| 'Mint'
| 'Citrus Peel'
| 'Coriander Seeds'
| 'Angelica Root'
| 'Cinnamon'
| 'Clove'
| 'Wheat'
| 'Rye'
| 'Corn'
| 'Potato'
| 'Barley'
| 'Sugarcane'
| 'Fruits'
| 'Grains'
| 'Juniper'
| 'Coriander'
| 'Lemon peel'
| 'Orange peel'
| 'Orris root'
| 'Cassia bark'
| 'Licorice root'
| 'Grapefruit peel'
| 'Elderflower'
| 'Apple'
| 'Blackcurrant'
| 'Butterscotch'
| 'Peach'
| 'Pear'
| 'Plum'
| 'Raspberries'
| 'Sorghum'
| 'Rice'
| 'Millet'
| 'Brown sugar'
| 'Buckwheat'
| 'Sweet Potato'
| 'Oat'
| 'Egg (Advocaat)'
| 'Strawberry'
| 'Almond'
| 'Banana'
| 'Chocolate'
| 'Sour Cherry'
| 'Violet'
| 'Lemon'
| 'Melon'
| 'Orange'
| 'Raspberry'
| 'Yuzu'
| 'Almond'
| 'Apricot Kernel'
| 'Hazelnut'
| 'Peanut'
| 'Pecan'
| 'Walnut'
export enum Ingredient {
Blanche = 'Blanche',
Anise = 'Anise',
Fennel = 'Fennel',
Hyssop = 'Hyssop',
Mint = 'Mint',
CitrusPeel = 'Citrus Peel',
CorianderSeeds = 'Coriander Seeds',
AngelicaRoot = 'Angelica Root',
Cinnamon = 'Cinnamon',
Clove = 'Clove',
Wheat = 'Wheat',
Rye = 'Rye',
Corn = 'Corn',
Potato = 'Potato',
Barley = 'Barley',
Sugarcane = 'Sugarcane',
Fruits = 'Fruits',
Grains = 'Grains',
Juniper = 'Juniper',
Coriander = 'Coriander',
LemonPeel = 'Lemon peel',
OrangePeel = 'Orange peel',
OrrisRoot = 'Orris root',
CassiaBark = 'Cassia bark',
LicoriceRoot = 'Licorice root',
GrapefruitPeel = 'Grapefruit peel',
Elderflower = 'Elderflower',
Apple = 'Apple',
Blackcurrant = 'Blackcurrant',
Butterscotch = 'Butterscotch',
Peach = 'Peach',
Pear = 'Pear',
Plum = 'Plum',
Raspberries = 'Raspberries',
Sorghum = 'Sorghum',
Rice = 'Rice',
Millet = 'Millet',
BrownSugar = 'Brown sugar',
Buckwheat = 'Buckwheat',
SweetPotato = 'Sweet Potato',
Oat = 'Oat',
EggAdvocaat = 'Egg (Advocaat)',
Strawberry = 'Strawberry',
Almond = 'Almond',
Banana = 'Banana',
Chocolate = 'Chocolate',
SourCherry = 'Sour Cherry',
Violet = 'Violet',
Lemon = 'Lemon',
Melon = 'Melon',
Orange = 'Orange',
Raspberry = 'Raspberry',
Yuzu = 'Yuzu',
ApricotKernel = 'Apricot Kernel',
Hazelnut = 'Hazelnut',
Peanut = 'Peanut',
Pecan = 'Pecan',
Walnut = 'Walnut'
}
export type Vintage = number | 'nv' | 'mv'
export enum VintageOption {
NV = 'NV',
MV = 'MV'
}
export interface StandardDrink {
'100ml': { AU: number; UK: number; US: number }
bottle: { AU: number; UK: number; US: number }
}
export enum ProductType {
Wine = 'Wine',
Spirit = 'Spirit',
Sake = 'Sake',
Coffee = 'Coffee'
}

122
src/types/review.ts Normal file

@ -0,0 +1,122 @@
import {
VisualAssessmentKey,
ClarityVisualAssessment,
NatureVisualAssessment,
WhiteColour,
AmberColour,
RoseColour,
RedColour,
BlueColour,
GreenColour,
PrimaryFlavoursAndAromasKey,
Condition,
Intensity,
Age,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
BlackFruit,
ChocolateFruit,
TropicalFruit,
MelonFruit,
Floral,
Vegetal,
Earth,
Microbial,
Oak,
Chocolate,
Oxidation,
Umami,
Balsamic,
Grain,
Dairy,
Anisoles,
Brettanomyces,
VolatileAcidity,
Reduction,
TextureAndBalanceKey,
Sweetness,
Concentration,
Body,
FlavourIntensity,
PalateLength,
ReasoningConcentration,
Quality,
ReadinessToDrink,
ReasoningKey,
TanninString,
TanninObject
} from './review/'
export enum RatingOption {
NoScore = 'NS'
}
export enum TastingNoteKey {
VisualAssessment = 'visualAssessment',
PrimaryFlavoursAndAromas = 'primaryFlavoursAndAromas',
TextureAndBalance = 'textureAndBalance'
}
export interface TastingNote {
[TastingNoteKey.VisualAssessment]: {
[VisualAssessmentKey.Clarity]: ClarityVisualAssessment
[VisualAssessmentKey.Nature]: NatureVisualAssessment
[VisualAssessmentKey.Colour]:
| WhiteColour
| AmberColour
| RoseColour
| RedColour
| BlueColour
| GreenColour
}
[TastingNoteKey.PrimaryFlavoursAndAromas]: {
[PrimaryFlavoursAndAromasKey.Condition]: Condition
[PrimaryFlavoursAndAromasKey.Intensity]: Intensity
[PrimaryFlavoursAndAromasKey.Age]: Age
[PrimaryFlavoursAndAromasKey.Fruit]:
| CitrusFruit
| AppleFruit
| StoneFruit
| RedFruit
| BlackFruit
| BlackFruit
| ChocolateFruit
| TropicalFruit
| MelonFruit
[PrimaryFlavoursAndAromasKey.Floral]: Floral
[PrimaryFlavoursAndAromasKey.Vegetal]: Vegetal
[PrimaryFlavoursAndAromasKey.Earth]: Earth
[PrimaryFlavoursAndAromasKey.Microbial]: Microbial
[PrimaryFlavoursAndAromasKey.Oak]: Oak
[PrimaryFlavoursAndAromasKey.Chocolate]: Chocolate
[PrimaryFlavoursAndAromasKey.Oxidation]: Oxidation
[PrimaryFlavoursAndAromasKey.Umami]: Umami
[PrimaryFlavoursAndAromasKey.Balsamic]: Balsamic
[PrimaryFlavoursAndAromasKey.Grain]: Grain
[PrimaryFlavoursAndAromasKey.Dairy]: Dairy
[PrimaryFlavoursAndAromasKey.Faults]:
| Anisoles
| Brettanomyces
| VolatileAcidity
| Reduction
}
[TastingNoteKey.TextureAndBalance]: {
[TextureAndBalanceKey.Sweetness]: Sweetness
[TextureAndBalanceKey.Acidity]: Concentration
[TextureAndBalanceKey.Tannin]: TanninString | TanninObject
[TextureAndBalanceKey.Alcohol]: Concentration
[TextureAndBalanceKey.Body]: Body
[TextureAndBalanceKey.FlavourIntensity]: FlavourIntensity
[TextureAndBalanceKey.PalateLength]: PalateLength
[TextureAndBalanceKey.Reasoning]: {
[ReasoningKey.Balance]: boolean
[ReasoningKey.Concentration]: ReasoningConcentration
[ReasoningKey.Complex]: boolean
}
[TextureAndBalanceKey.Quality]: Quality
[TextureAndBalanceKey.Age]: number // tasting date-producers bottling date
[TextureAndBalanceKey.ReadinessToDrink]: ReadinessToDrink
}
}

@ -0,0 +1,93 @@
// Free Run (sake, wine, spirits)
export enum WhiteColour {
WaterWhite = 'Water white',
LemonGreen = 'Lemon-Green',
Lemon = 'Lemon',
Gold = 'Gold',
WhiteBrown = 'White-Brown'
}
// Skin Contact (wine, spirits)
export enum AmberColour {
AmberOrange = 'Amber-Orange',
Amber = 'Amber'
}
// Blush (wine, spirits)
export enum RoseColour {
Pink = 'Pink',
Salmon = 'Salmon',
RoseOrange = 'Rose-Orange',
OnionSkin = 'Onion-Skin'
}
// Extended Maceration (wine, spirits)
export enum RedColour {
Purple = 'Purple',
Ruby = 'Ruby',
Garnet = 'Garnet',
Tawny = 'Tawny',
RedBrown = 'Red-Brown'
}
// Liqueurs (spirits)
export enum BlueColour {
BluePale = 'Blue-Pale',
BlueDark = 'Blue-Dark'
}
// Liqueurs (spirits)
export enum GreenColour {
GreenPale = 'Green-Pale',
GreenDark = 'Green-Dark'
}
export enum WineColour {
WaterWhite = WhiteColour.WaterWhite,
LemonGreen = WhiteColour.LemonGreen,
Lemon = WhiteColour.Lemon,
Gold = WhiteColour.Gold,
WhiteBrown = WhiteColour.WhiteBrown,
AmberOrange = AmberColour.AmberOrange,
Amber = AmberColour.Amber,
Pink = RoseColour.Pink,
Salmon = RoseColour.Salmon,
OnionSkin = RoseColour.OnionSkin,
RoseOrange = RoseColour.RoseOrange,
Purple = RedColour.Purple,
Ruby = RedColour.Ruby,
Garnet = RedColour.Garnet,
Tawny = RedColour.Tawny,
RedBrown = RedColour.RedBrown
}
export enum SakeColour {
WaterWhite = WhiteColour.WaterWhite,
LemonGreen = WhiteColour.LemonGreen,
Lemon = WhiteColour.Lemon,
Gold = WhiteColour.Gold,
WhiteBrown = WhiteColour.WhiteBrown
}
export enum SpiritColour {
WaterWhite = WhiteColour.WaterWhite,
LemonGreen = WhiteColour.LemonGreen,
Lemon = WhiteColour.Lemon,
Gold = WhiteColour.Gold,
WhiteBrown = WhiteColour.WhiteBrown,
Amber = AmberColour.Amber,
AmberOrange = AmberColour.AmberOrange,
Pink = RoseColour.Pink,
Salmon = RoseColour.Salmon,
OnionSkin = RoseColour.OnionSkin,
RoseOrange = RoseColour.RoseOrange,
Purple = RedColour.Purple,
Ruby = RedColour.Ruby,
Garnet = RedColour.Garnet,
Tawny = RedColour.Tawny,
RedBrown = RedColour.RedBrown,
BluePale = BlueColour.BluePale,
BlueDark = BlueColour.BlueDark,
GreenPale = GreenColour.GreenPale,
GreenDark = GreenColour.GreenDark
}

@ -0,0 +1,4 @@
export * from './visualAssessment'
export * from './colour'
export * from './primaryFlavoursAndAromas'
export * from './textureAndBalance'

@ -0,0 +1,232 @@
export enum PrimaryFlavoursAndAromasKey {
Condition = 'condition',
Intensity = 'intensity',
Age = 'age',
Fruit = 'fruit',
Floral = 'floral',
Vegetal = 'vegetal',
Earth = 'earth',
Microbial = 'microbial',
Oak = 'oak',
Chocolate = 'chocolate',
Oxidation = 'oxidation',
Umami = 'umami',
Balsamic = 'balsamic',
Grain = 'grain',
Dairy = 'dairy',
Faults = 'faults'
}
export enum RequiredPrimaryFlavoursAndAromasKey {
Condition = PrimaryFlavoursAndAromasKey.Condition,
Intensity = PrimaryFlavoursAndAromasKey.Intensity,
Age = PrimaryFlavoursAndAromasKey.Age
}
export enum Condition {
Clean = 'Clean',
Unclean = 'Unclean'
}
export enum Intensity {
Light = 'Light',
Medium = 'Medium',
Pronounced = 'Pronounced'
}
export enum Age {
Youthful = 'Youthful',
Developing = 'Developing',
Developed = 'Developed',
Oxidised = 'Oxidised',
Passed = 'Passed'
}
export enum CitrusFruit {
Grapefruit = 'Grapefruit',
Lemon = 'Lemon',
Lime = 'Lime',
Marmalade = 'Marmalade',
Orange = 'Orange',
Yuzu = 'Yuzu'
}
export enum AppleFruit {
Green = 'Green',
Red = 'Red',
Ripe = 'Ripe'
}
export enum StoneFruit {
Apricot = 'Apricot ',
Nectarine = 'Nectarine',
Peach = 'Peach',
Plum = 'Plum '
}
export enum RedFruit {
Cherry = 'Cherry',
Cranberry = 'Cranberry',
Pomegranate = 'Pomegranate',
Raspberry = 'Raspberry',
SourCherry = 'Sour Cherry',
Strawberry = 'Strawberry'
}
export enum BlackFruit {
Blackberry = 'Blackberry',
Blackcurrant = 'Blackcurrant',
Boysenberry = 'Boysenberry',
Blueberry = 'Blueberry',
Olive = 'Olive'
}
export enum ChocolateFruit {
Chocolate = 'Chocolate'
}
export enum TropicalFruit {
Banana = 'Banana',
Mango = 'Mango',
Passionfruit = 'Passionfruit',
Pineapple = 'Pineapple'
}
export enum MelonFruit {
Cantaloupe = 'Cantaloupe',
Honeydew = 'Honeydew'
}
export enum Floral {
Acacia = 'Acacia',
Elderflower = 'Elderflower',
Hibiscus = 'Hibiscus',
Honeysuckle = 'Honeysuckle',
Jasmine = 'Jasmine',
Lavender = 'Lavender',
Lilac = 'Lilac',
OrangeBlossom = 'Orange Blossom',
Potpourri = 'Potpourri',
Rose = 'Rose',
Vanilla = 'Vanilla',
Violet = 'Violet'
}
export enum Vegetal {
Grass = 'Grass',
Hay = 'Hay',
Herbaceous = 'Herbaceous',
Basil = 'Basil',
Mint = 'Mint',
Eucalyptus = 'Eucalyptus',
BlackTea = 'Black Tea',
Capsicum = 'Capsicum',
Gooseberry = 'Gooseberry',
Tomato = 'Tomato'
}
export enum Earth {
Gravel = 'Gravel',
Kerosene = 'Kerosene',
RedBeet = 'Red Beet',
Rocks = 'Rocks',
Slate = 'Slate',
Soil = 'Soil',
Terracotta = 'Terracotta'
}
export enum Microbial {
Mushroom = 'Mushroom',
Botrytis = 'Botrytis',
Beeswax = 'Beeswax',
Ginger = 'Ginger',
Saffron = 'Saffron',
Yeast = 'Yeast',
Bread = 'Bread',
Brioche = 'Brioche',
Toast = 'Toast'
}
export enum Oak {
CigarBox = 'Cigar Box',
Coconut = 'Coconut',
Dill = 'Dill',
Smoke = 'Smoke',
Spices = 'Spices',
Cinnamon = 'Cinnamon',
Nutmeg = 'Nutmeg',
Cloves = 'Cloves'
}
export enum Chocolate {
MilkChocolate = 'Milk Chocolate',
DarkChocolate = 'Dark Chocolate'
}
export enum Oxidation {
Aldehydes = 'Aldehydes',
Caramel = 'Caramel',
Sherry = 'Sherry',
Staleness = 'Staleness',
Toffee = 'Toffee'
}
export enum Umami {
FishSauce = 'Fish Sauce',
Soy = 'Soy'
}
export enum Balsamic {
Balsamic = 'Balsamic'
}
export enum Grain {
CookedRice = 'Cooked Rice',
RawRice = 'Raw Rice',
SteamedRice = 'Steamed Rice',
Cereal = 'Cereal',
Barley = 'Barley',
Oat = 'Oat',
Wheat = 'Wheat',
Grains = 'Grains',
Corn = 'Corn',
Malt = 'Malt'
}
export enum Dairy {
Butter = 'Butter',
Cream = 'Cream',
Milk = 'Milk',
Yogurt = 'Yogurt'
}
export enum Anisoles {
Mustiness = 'Mustiness',
Trichloroanisole = 'Trichloroanisole',
WetCardboard = 'Wet cardboard'
}
export enum Brettanomyces {
Animal = 'Animal',
Farmyard = 'Farmyard',
Iodine = 'Iodine',
Leather = 'Leather',
Meaty = 'Meaty',
Vinyl = 'Vinyl'
}
export enum VolatileAcidity {
Solvent = 'Solvent',
NailVarnishRemover = 'Nail varnish remover',
Vinegar = 'Vinegar'
}
export enum Reduction {
Cabbage = 'Cabbage',
Eggs = 'Eggs',
Garlic = 'Garlic',
Mercaptans = 'Mercaptans',
Onion = 'Onion',
Rubber = 'Rubber',
Sweat = 'Sweat'
}

@ -0,0 +1,106 @@
export enum TextureAndBalanceKey {
Sweetness = 'sweetness',
Acidity = 'acidity',
Tannin = 'tannin',
Alcohol = 'alcohol',
Body = 'body',
FlavourIntensity = 'flavourIntensity',
PalateLength = 'palateLength',
Reasoning = 'reasoning',
Quality = 'quality',
Age = 'age',
ReadinessToDrink = 'readinessToDrink'
}
export enum Sweetness {
Dry = 'Dry',
OffDry = 'Off-dry',
Medium = 'Medium',
Sweet = 'Sweet',
Luscious = 'Luscious'
}
export enum Concentration {
Low = 'Low',
Medium = 'Medium',
High = 'High'
}
export enum TanninString {
NA = 'NA'
}
export interface TanninObject {
[Concentration.Low]: {
[key in TanninType]: RipeTannin | UnripeTannin
}
[Concentration.Medium]: {
[key in TanninType]: RipeTannin | UnripeTannin
}
[Concentration.High]: {
[key in TanninType]: RipeTannin | UnripeTannin
}
}
export enum TanninType {
Ripe = 'Ripe',
Unripe = 'Unripe'
}
export enum RipeTannin {
Soft = 'Soft',
FineGrained = 'Fine-grained',
Coarse = 'Coarse'
}
export enum UnripeTannin {
Green = 'Green',
Stalky = 'Stalky'
}
export enum Body {
Light = 'Light',
Medium = 'Medium',
Full = 'Full'
}
export enum FlavourIntensity {
Light = 'Light',
Medium = 'Medium',
Pronounced = 'Pronounced'
}
export enum PalateLength {
Short = 'Short',
Medium = 'Medium',
Pronounced = 'Pronounced',
Exceptional = 'Exceptional'
}
export enum ReasoningConcentration {
Low = 'Low',
High = 'High'
}
export enum Quality {
NS = 'NS',
Poor = 'Poor',
Acceptable = 'Acceptable',
Good = 'Good',
VeryGood = 'Very good',
Excellent = 'Excellent',
Outstanding = 'Outstanding'
}
export enum ReadinessToDrink {
TooYoung = 'Too young',
DrinkWithPotentialForAgeing = 'Drink with potential for ageing',
DrinkNow = 'Drink Now',
TooOldPassed = 'Too old/Passed'
}
export enum ReasoningKey {
Balance = 'balance',
Concentration = 'concentration',
Complex = 'complex'
}

@ -0,0 +1,17 @@
export enum VisualAssessmentKey {
Clarity = 'clarity',
Nature = 'nature',
Colour = 'colour'
}
export enum ClarityVisualAssessment {
Clear = 'Clear',
Cloudy = 'Cloudy',
Opaque = 'Opaque'
}
export enum NatureVisualAssessment {
Still = 'Still',
Frizzante = 'Frizzante',
Sparkling = 'Sparkling'
}

@ -1,4 +1,4 @@
export enum Routes {
export enum Route {
Users = '/users',
NostrEvents = '/nostr',
Reviews = '/reviews',

@ -1,9 +1,130 @@
export type SakeDesignation =
| 'table'
| 'pure'
| 'blended'
| 'mirin:new'
| 'mirin:true'
| 'mirin:salt'
export enum SakeDesignation {
Table = 'Table',
Pure = 'Pure',
Blended = 'Blended' // Blended with Spirit - up to 10% of final volume
}
export type SakeStarter = 'Kimoto' | 'Sokujō' | 'Yamahai'
export enum TableSakeDesignation {
FutsūShu = 'Futsū-shu'
}
export enum PureSakeDesignation {
Junmai = 'Junmai',
JunmaiGinjo = 'Junmai Ginjo',
JunmaiDaiginjo = 'Junmai Daiginjo'
}
export enum BlendedSakeDesignation {
Honjozo = 'Honjozo',
Ginjo = 'Ginjo',
Daiginjo = 'Daiginjo'
}
export enum SakeStarter {
Kimoto = 'Kimoto',
Sokujō = 'Sokujō',
Yamahai = 'Yamahai'
}
export enum SakeYeastStrain {
KyokaiNo6 = 'Kyokai No. 6',
KyokaiNo7 = 'Kyokai No. 7',
KyokaiNo9 = 'Kyokai No. 9',
Sake101 = 'Sake 101',
Sake301 = 'Sake 301',
AK1 = 'AK-1',
K61 = 'K6-1',
K91 = 'K9-1',
KA11 = 'KA-11',
ShinseiYeast = 'Shinsei Yeast',
HokkaidoYeast = 'Hokkaido Yeast',
TohokuYeast = 'Tohoku Yeast',
KansaiYeast = 'Kansai Yeast',
ChugokuYeast = 'Chugoku Yeast',
ShikokuYeast = 'Shikoku Yeast',
KyushuYeast = 'Kyushu Yeast',
GinjoYeast = 'Ginjo Yeast',
DaiginjoYeast = 'Daiginjo Yeast',
JunmaiYeast = 'Junmai Yeast',
KimotoYeast = 'Kimoto Yeast',
YamahaiYeast = 'Yamahai Yeast'
}
export enum SakeKoji {
AkitaKoji = 'Akita Koji',
HiguchiKoji = 'Higuchi Koji',
HiroshimaKoji = 'Hiroshima Koji',
KiKoji = 'Ki Koji',
KumamotoKoji = 'Kumamoto Koji',
KyokaiKoji = 'Kyokai Koji',
NaganoKoji = 'Nagano Koji',
NishikiKoji = 'Nishiki Koji',
ShinmeiKoji = 'Shinmei Koji',
TakahashiKoji = 'Takahashi Koji',
AssociationNo6 = 'Association No. 6',
AssociationNo9 = 'Association No. 9',
AssociationNo10 = 'Association No. 10',
KA1 = 'KA-1',
KA4 = 'KA-4',
K7 = 'K7',
ShinseiKoji = 'Shinsei Koji',
HokkaidoKoji = 'Hokkaido Koji',
TohokuKoji = 'Tohoku Koji',
KansaiKoji = 'Kansai Koji',
ChugokuKoji = 'Chugoku Koji',
ShikokuKoji = 'Shikoku Koji',
KyushuKoji = 'Kyushu Koji'
}
export interface SakePolishMin {
min: number
}
export enum SakeCharacteristic {
LightAndRefreshing = 'Light and Refreshing',
CleanAndCrisp = 'Clean and Crisp',
FruityAndAromatic = 'Fruity and Aromatic',
RichAndUmami = 'Rich and Umami',
ComplexAndLayered = 'Complex and Layered',
RobustAndFullBodied = 'Robust and Full-Bodied'
}
export enum SakeVolume {
'0.18L' = '0.18L',
'0.3L' = '0.3L',
'0.5L' = '0.5L',
'0.72L' = '0.72L',
'1.8L' = '1.8L',
'3.6L' = '3.6L'
}
export enum RiceVarietal {
Blended = 'BLENDED',
AkitaSakeKomachi = 'Akita Sake Komachi',
Akitakomachi = 'Akitakomachi',
DewaSansan = 'Dewa Sansan',
Ginnosei = 'Ginnosei',
Gohyakumangoku = 'Gohyakumangoku',
HanaFubuki = 'Hana-Fubuki',
HattanNishiki = 'Hattan-Nishiki',
Hinohikari = 'Hinohikari',
Hitomebore = 'Hitomebore',
HyogoKitaNishiki = 'Hyogo Kita Nishiki',
Ibaraki5 = 'Ibaraki 5',
KairyōMai = 'Kairyō-mai',
KitaNishiki = 'Kita Nishiki',
KokuryūMai = 'Kokuryū-mai',
MiyamaNishiki = 'Miyama Nishiki',
NakateShinseiki = 'Nakate Shinseiki',
NiigataKoshihikari = 'Niigata Koshihikari',
Notohikari = 'Notohikari',
Ōmachi = 'Ōmachi',
Sakamai = 'Sakamai',
Sankei65 = 'Sankei 65',
Shinriki = 'Shinriki',
Tamazakae = 'Tamazakae',
Tōkai14 = 'Tōkai 14',
YamadaNishiki = 'Yamada Nishiki',
Yamagata4 = 'Yamagata 4',
YumeIkkon = 'Yume-Ikkon'
}

@ -1,172 +1,93 @@
export type SpiritType = 'white' | 'dark' | 'liqueurs'
export type SpiritVariant =
| 'Absinthe'
| 'Pastis'
| 'Vodka'
| 'Genever'
| 'Gin'
| 'Mezcal'
| 'Rum'
| 'Eau de Vie'
| 'Grappa'
| 'Baijiu'
| 'Soju'
| 'Absinthe'
| 'Brandy'
| 'Calvados'
| 'Chartreuse'
| 'Genever'
| 'Mezcal'
| 'Rum'
| 'Slivovitz'
| 'Whiskey'
| 'Amaro'
| 'Coffee'
| 'Cream'
| 'Creme'
| 'Flowers'
| 'Fruit'
| 'Herb'
| 'Honey'
| 'Nut'
export interface WhiteSpiritKind {
Absinthe: ['Blanche']
Pastis: [
'Anise',
'Fennel',
'Licorice Root',
'Hyssop',
'Mint',
'Citrus Peel',
'Coriander Seeds',
'Angelica Root',
'Cinnamon',
'Clove'
]
Vodka: [
'Wheat',
'Rye',
'Corn',
'Potato',
'Barley',
'Sugarcane',
'Fruits',
'Grains'
]
Genever: [
{
Young: 'Juniper'
}
]
Gin: [
{
'London Dry': [
'Juniper',
'Coriander',
'Angelica root',
'Lemon peel',
'Orange peel',
'Orris root',
'Cassia bark',
'Licorice root',
'Grapefruit peel',
'Elderflower'
]
},
'Plymouth'
]
Mezcal: [{ Joven: ['espadín', 'tepeztate', 'Tequilana (blue)', 'tobalá'] }]
Rum: ['Blanco', 'Cachaça', 'Platino', 'Agricole']
'Eau de Vie': [
'Apple (Pomme)',
'Blackcurrant (Kirsch)',
'Butterscotch (Schnapps)',
'Peach (Pêche, Schnapps)',
'Pear (Poire William)',
'Plum (Mirabelle, Slivovitz, Rakia)',
'Raspberries (Framboise)'
]
Grappa: ['Marc', 'Pisco']
Baijiu: ['Sorghum', 'Wheat', 'Barley', 'Rice', 'Millet']
Soju: ['Barley', 'Brown sugar', 'Buckwheat', 'Rice', 'Sweet Potato']
export enum SpiritType {
White = 'White',
Dark = 'Dark',
Liqueurs = 'Liqueurs'
}
export interface DarkSpiritKind {
Absinthe: ['Jaune', 'Verte']
Brandy: [
{
Grape: [
'VS',
'VSOP',
'XO',
'Beyond Age',
'Solera',
'Solera Reserva',
'Solera Gran Reserva'
]
}
]
Calvados: ['Apple', 'Pear']
Chartreuse: ['Green', 'Yellow']
Genever: [{ Old: ['Juniper'] }, { Coren: ['Juniper'] }]
Mezcal: ['Reposado', 'Abuelo', 'Añejo', 'Extra Añejo']
Rum: [
{
Sugar: [
'Cachaca (amarela/ouro)',
'Dark Rum',
'Gold Rum',
'Over-proof',
'Premium',
'Spiced'
]
}
]
Slivovitz: []
Whiskey: ['Barley', 'Rye', 'Wheat', 'Corn', 'Oat', 'Rice']
export enum SpiritVariant {
Absinthe = 'Absinthe',
Pastis = 'Pastis',
Vodka = 'Vodka',
Gin = 'Gin',
Mezcal = 'Mezcal',
Eau = 'Eau de Vie',
Grappa = 'Grappa',
Baijiu = 'Baijiu',
Soju = 'Soju',
Brandy = 'Brandy',
Calvados = 'Calvados',
Chartreuse = 'Chartreuse',
Genever = 'Genever',
Rum = 'Rum',
Slivovitz = 'Slivovitz',
Whiskey = 'Whiskey',
Amaro = 'Amaro',
Coffee = 'Coffee',
Cream = 'Cream',
Creme = 'Creme',
Flowers = 'Flowers',
Fruit = 'Fruit',
Herb = 'Herb',
Honey = 'Honey',
Nut = 'Nut'
}
export interface LiqueursSpiritKind {
Amaro: []
Coffee: []
Cream: [
'Egg (Advocaat)',
'Amarula',
'Rum',
'Strawberry',
'Whiskey (Baileys etc)'
]
Creme: [
'Almond',
'Banana',
'Blackcurrant',
'Chocolate',
'Peach',
'Sour Cherry',
'Violet'
]
Flowers: ['Rose', 'Violet', 'Elderflower']
Fruit: [
'Blackcurrant',
'Lemon',
'Melon',
'Orange',
'Peach',
'Plum',
'Raspberry',
'Yuzu'
]
Herb: [
'Anise',
'Dom Benedictine',
'Bitters',
'Ginger',
'Jägermeister',
'Metaxa',
'Mint'
]
Honey: ['Licor 43', 'Rum', 'Vodka', 'Whiskey']
Nut: ['Almond', 'Apricot Kernel', 'Hazelnut', 'Peanut', 'Pecan', 'Walnut']
export enum SpiritVolume {
'0.05L' = '0.05L',
'0.15L' = '0.15L',
'0.25L' = '0.25L',
'0.375L' = '0.375L',
'0.5L' = '0.5L',
'0.7L' = '0.7L',
'1L' = '1L'
}
export enum SpiritCharacteristic {
LightAndNeutral = 'Light and Neutral',
FruityAndAromatic = 'Fruity and Aromatic',
HerbalAndBotanical = 'Herbal and Botanical',
SweetAndSyrupy = 'Sweet and Syrupy',
SmokyAndSpicy = 'Smoky and Spicy',
RichAndFullBodied = 'Rich and Full-Bodied'
}
export enum WhiteSpiritVariant {
Absinthe = 'Absinthe',
Pastis = 'Pastis',
Vodka = 'Vodka',
Genever = 'Genever',
Gin = 'Gin',
Mezcal = 'Mezcal',
Rum = 'Rum',
EauDeVie = 'Eau de Vie',
Grappa = 'Grappa',
Baijiu = 'Baijiu',
Soju = 'Soju',
Aquavit = 'Aquavit',
Arrack = 'Arrack'
}
export enum DarkSpiritVariant {
Absinthe = 'Absinthe',
Brandy = 'Brandy',
Calvados = 'Calvados',
Chartreuse = 'Chartreuse',
Genever = 'Genever',
Mezcal = 'Mezcal',
Rum = 'Rum',
Slivovitz = 'Slivovitz',
Arrack = 'Arrack',
Whiskey = 'Whiskey'
}
export enum LiqueursSpiritVariant {
Amaro = 'Amaro',
Coffee = 'Coffee',
Cream = 'Cream',
Creme = 'Creme',
Flowers = 'Flowers',
Fruit = 'Fruit',
Herb = 'Herb',
Honey = 'Honey',
Nut = 'Nut'
}

@ -1,5 +1,5 @@
export enum UserRole {
User = 'user',
Reviewer = 'reviewer',
Producer = 'producer'
User = 'User',
Reviewer = 'Reviewer',
Producer = 'Producer' // not able to leave a review
}

@ -1,5 +1,358 @@
export type WineType = 'white' | 'amber' | 'rose' | 'red'
export enum WineType {
White = 'White',
Amber = 'Amber',
Rose = 'Rose',
Red = 'Red'
}
export type Viticulture = 'biodynamic' | 'organic' | 'conventional'
export enum WhiteWineCharacteristic {
LightAromatic = 'Light Aromatic',
TexturalAndSavory = 'Textural and Savory',
RichAndFruitForward = 'Rich and Fruit Forward'
}
export type BottleClosure = 'cork' | 'crown-seal' | 'screwcap'
export enum AmberWineCharacteristic {
TexturalAndAromatic = 'Textural and Aromatic',
StructuralAndSavory = 'Structural and Savory',
PowerAndPresence = 'Power and Presence'
}
export enum RoseWineCharacteristic {
LightAndFruitForward = 'Light and Fruit Forward',
TexturalAndSavory = 'Textural and Savory',
StructuralAndAromatic = 'Structural and Aromatic'
}
export enum RedWineCharacteristic {
LightAndFruitForward = 'Light and Fruit Forward',
StructuralAndAromatic = 'Structural and Aromatic',
TexturalAndSavory = 'Textural and Savory',
PowerAndPresence = 'Power and Presence'
}
export enum WineStyle {
BubblesAndFizz = 'Bubbles + Fizz',
Table = 'Table',
Dessert = 'Dessert',
Fortified = 'Fortified',
Vermouth = 'Vermouth'
}
export enum Viticulture {
Biodynamic = 'Biodynamic',
Organic = 'Organic',
Conventional = 'Conventional'
}
export enum BottleClosure {
Cork = 'Cork',
CrownSeal = 'Crown-seal',
Screwcap = 'Screwcap'
}
export interface WineRegion {
[key: string]:
| string[]
| { [key: string]: string[] | { [key: string]: string[] } }
}
export enum WineVolume {
'0.05L' = '0.05L',
'0.187L' = '0.187L',
'0.375L' = '0.375L',
'0.5L' = '0.5L',
'0.75L' = '0.75L',
'1.5L' = '1.5L',
'3L' = '3L',
'6L' = '6L',
'12L' = '12L'
}
export enum GrapeVarietal {
FieldBlend = 'FIELD BLEND',
Acolon = 'Acolon',
Albariño = 'Albariño',
Aligoté = 'Aligoté',
Altesse = 'Altesse',
Amigne = 'Amigne',
Ansonica = 'Ansonica',
AntãoVaz = 'Antão Vaz',
Arbane = 'Arbane',
ArboisBlanc = 'Arbois Blanc',
Arneis = 'Arneis',
Arrufiac = 'Arrufiac',
Assyrtiko = 'Assyrtiko',
Auxerrois = 'Auxerrois',
Bacchus = 'Bacchus',
Biancolella = 'Biancolella',
Bical = 'Bical',
BlancDuBois = 'Blanc du Bois',
BombinoBianco = 'Bombino Bianco',
Bourboulenc = 'Bourboulenc',
Bovale = 'Bovale',
Catarratto = 'Catarratto',
Chardonnay = 'Chardonnay',
Chasselas = 'Chasselas',
CheninBlanc = 'Chenin Blanc',
Clairette = 'Clairette',
Colombard = 'Colombard',
Cortese = 'Cortese',
Courbu = 'Courbu',
Couston = 'Couston',
Crouchen = 'Crouchen',
Duras = 'Duras',
Elbling = 'Elbling',
Emir = 'Emir',
Falanghina = 'Falanghina',
FernãoPires = 'Fernão Pires',
Fiano = 'Fiano',
FolleBlanche = 'Folle Blanche',
Friulano = 'Friulano',
Furmint = 'Furmint',
Gaglioppo = 'Gaglioppo',
GamayBlanc = 'Gamay Blanc',
GarnachaBlanca = 'Garnacha Blanca',
Gascon = 'Gascon',
Gavi = 'Gavi',
Gewürztraminer = 'Gewürztraminer',
Godello = 'Godello',
GouaisBlanc = 'Gouais Blanc',
Grechetto = 'Grechetto',
GrenacheBlanc = 'Grenache Blanc',
GrosManseng = 'Gros Manseng',
GrünerVeltliner = 'Grüner Veltliner',
Hárslevelü = 'Hárslevelü',
Huxelrebe = 'Huxelrebe',
Inzolia = 'Inzolia',
Jacquère = 'Jacquère',
Kerner = 'Kerner',
KleinConstantia = 'Klein Constantia',
Kunegund = 'Kunegund',
Lagarino = 'Lagarino',
Luglienga = 'Luglienga',
Macabeo = 'Macabeo',
Malvasia = 'Malvasia',
Marsanne = 'Marsanne',
MelonDeBourgogne = 'Melon de Bourgogne',
MerlotBlanc = 'Merlot Blanc',
Minutolo = 'Minutolo',
Moscato = 'Moscato',
MüllerThurgau = 'Müller-Thurgau',
Muscadelle = 'Muscadelle',
Muscat = 'Muscat',
Nascetta = 'Nascetta',
Nosiola = 'Nosiola',
Nuragus = 'Nuragus',
Okçular = 'Okçular',
Ondenc = 'Ondenc',
Oran = 'Oran',
PacherencDuVicBilh = 'Pacherenc du Vic-Bilh',
PansaBlanca = 'Pansa Blanca',
Parellada = 'Parellada',
Pecorino = 'Pecorino',
PedroXiménez = 'Pedro Ximénez',
PetitManseng = 'Petit Manseng',
PetitMeslier = 'Petit Meslier',
Picolit = 'Picolit',
Picpoul = 'Picpoul',
PinotBlanc = 'Pinot Blanc',
PinotGrigio = 'Pinot Grigio',
PinotGris = 'Pinot Gris',
PinotMeunier = 'Pinot Meunier',
PinotNoirBlanc = 'Pinot Noir Blanc',
PiquepoulBlanc = 'Piquepoul Blanc',
PlavacMali = 'Plavac Mali',
Raboso = 'Raboso',
Riesling = 'Riesling',
RoterVeltliner = 'Roter Veltliner',
Roupeiro = 'Roupeiro',
Roussanne = 'Roussanne',
SauvignonBlanc = 'Sauvignon Blanc',
Savagnin = 'Savagnin',
Scheurebe = 'Scheurebe',
Sémillon = 'Sémillon',
Sercial = 'Sercial',
Siegerrebe = 'Siegerrebe',
Silvaner = 'Silvaner',
SouvignierGris = 'Souvignier Gris',
Sylvaner = 'Sylvaner',
Taminga = 'Taminga',
TintaAmarela = 'Tinta Amarela',
TintaBarroca = 'Tinta Barroca',
Torrontés = 'Torrontés',
Trebbiano = 'Trebbiano',
Treixadura = 'Treixadura',
UgniBlanc = 'Ugni Blanc',
Verdejo = 'Verdejo',
Verdelho = 'Verdelho',
Verdicchio = 'Verdicchio',
Vermentino = 'Vermentino',
Viennoise = 'Viennoise',
Viognier = 'Viognier',
Vitovska = 'Vitovska',
Xarello = 'Xarello',
Xynomavro = 'Xynomavro',
ZeleniVrh = 'Zeleni Vrh',
Abbuoto = 'Abbuoto',
Agiorgitiko = 'Agiorgitiko',
Aglianico = 'Aglianico',
Aladasturi = 'Aladasturi',
Albarossa = 'Albarossa',
AlicanteBouschet = 'Alicante Bouschet',
Ancellotta = 'Ancellotta',
Aragonez = 'Aragonez',
Aramon = 'Aramon',
Areni = 'Areni',
Baga = 'Baga',
Barbera = 'Barbera',
Bastardo = 'Bastardo',
Béquignol = 'Béquignol',
BlackMuscat = 'Black Muscat',
Blaufränkisch = 'Blaufränkisch',
Bobal = 'Bobal',
Boğazkere = 'Boğazkere',
Bonarda = 'Bonarda',
Bouchet = 'Bouchet',
Brachetto = 'Brachetto',
CabernetFranc = 'Cabernet Franc',
CabernetSauvignon = 'Cabernet Sauvignon',
CaiñoTinto = 'Caiño Tinto',
Calabrese = 'Calabrese',
Canaiolo = 'Canaiolo',
Cannonau = 'Cannonau',
Carignan = 'Carignan',
Carmenère = 'Carmenère',
Castelão = 'Castelão',
Cataratto = 'Cataratto',
Chambourcin = 'Chambourcin',
Charbono = 'Charbono',
Chenanson = 'Chenanson',
Cinsault = 'Cinsault',
Colonnata = 'Colonnata',
Colorino = 'Colorino',
Corvina = 'Corvina',
Corvinone = 'Corvinone',
Counoise = 'Counoise',
Croatina = 'Croatina',
Dolcetto = 'Dolcetto',
Dornfelder = 'Dornfelder',
Durif = 'Durif',
Enantio = 'Enantio',
Fer = 'Fer',
Ferrandina = 'Ferrandina',
FeteascăNeagră = 'Fetească Neagră',
FogliaTonda = 'Foglia Tonda',
Freisa = 'Freisa',
Frühburgunder = 'Frühburgunder',
Gamay = 'Gamay',
Garnacha = 'Garnacha',
Girò = 'Girò',
GodelloTinto = 'Godello Tinto',
Graciano = 'Graciano',
Greco = 'Greco',
Grenache = 'Grenache',
Grolleau = 'Grolleau',
GrosCabernet = 'Gros Cabernet',
Guanciale = 'Guanciale',
Helfensteiner = 'Helfensteiner',
Heroldrebe = 'Heroldrebe',
Kadarka = 'Kadarka',
KalecikKarasi = 'Kalecik Karasi',
Kékfrankos = 'Kékfrankos',
Lagrein = 'Lagrein',
Lambrusco = 'Lambrusco',
Liatiko = 'Liatiko',
ListánNegro = 'Listán Negro',
LoureiroTinto = 'Loureiro Tinto',
Magliocco = 'Magliocco',
Malbec = 'Malbec',
MalvasiaNera = 'Malvasia Nera',
Mammolo = 'Mammolo',
Mandolari = 'Mandolari',
MansengNoir = 'Manseng Noir',
Marzemino = 'Marzemino',
Mauzac = 'Mauzac',
Mavroudi = 'Mavroudi',
Mencia = 'Mencia',
Merlot = 'Merlot',
Miro = 'Miro',
Mission = 'Mission',
Molinara = 'Molinara',
Monastrell = 'Monastrell',
Montepulciano = 'Montepulciano',
MoraviaAgria = 'Moravia Agria',
Morellino = 'Morellino',
Mourvèdre = 'Mourvèdre',
Müllerrebe = 'Müllerrebe',
MuscatRouge = 'Muscat Rouge',
Narince = 'Narince',
Nebbiolo = 'Nebbiolo',
Negoska = 'Negoska',
NerelloCappuccio = 'Nerello Cappuccio',
NerelloMascalese = 'Nerello Mascalese',
Öküzgözü = 'Öküzgözü',
Pais = 'Pais',
Pallagrello = 'Pallagrello',
Passetoutgrain = 'Passetoutgrain',
Patrigone = 'Patrigone',
PetitBouschet = 'Petit Bouschet',
PetitVerdot = 'Petit Verdot',
Pignatello = 'Pignatello',
PinotNoir = 'Pinot Noir',
Pinotage = 'Pinotage',
PiquepoulNoir = 'Piquepoul Noir',
Primitivo = 'Primitivo',
Priorat = 'Priorat',
Prokupac = 'Prokupac',
Refosco = 'Refosco',
RibollaGialla = 'Ribolla Gialla',
Robola = 'Robola',
Romano = 'Romano',
Rondinella = 'Rondinella',
Rossese = 'Rossese',
Roussin = 'Roussin',
RubyCabernet = 'Ruby Cabernet',
Sagrantino = 'Sagrantino',
Sangiovese = 'Sangiovese',
Sansovino = 'Sansovino',
Saperavi = 'Saperavi',
Schioppettino = 'Schioppettino',
Sciacarello = 'Sciacarello',
SémillonRouge = 'Sémillon Rouge',
Shiraz = 'Shiraz',
SilvanerRouge = 'Silvaner Rouge',
Souzão = 'Souzão',
Spanna = 'Spanna',
StLaurent = 'St. Laurent',
Sultani = 'Sultani',
Syrah = 'Syrah',
Tannat = 'Tannat',
Tarrango = 'Tarrango',
Tempranillo = 'Tempranillo',
Teroldego = 'Teroldego',
TintaFrancisca = 'Tinta Francisca',
TintaRoriz = 'Tinta Roriz',
TintoFino = 'Tinto Fino',
TourigaFranca = 'Touriga Franca',
TourigaNacional = 'Touriga Nacional',
Trincadeira = 'Trincadeira',
Trollinger = 'Trollinger',
UvaDiTroia = 'Uva di Troia',
UvaLonganesi = 'Uva Longanesi',
UvaRara = 'Uva Rara',
Vaccarèse = 'Vaccarèse',
Valdiguié = 'Valdiguié',
Valpolicella = 'Valpolicella',
VermentinoNero = 'Vermentino Nero',
ViennoiseRouge = 'Viennoise Rouge',
Vignoles = 'Vignoles',
Vinhão = 'Vinhão',
ViognierRouge = 'Viognier Rouge',
VitisRiparia = 'Vitis Riparia',
ZanteCurrant = 'Zante Currant',
Zeni = 'Zeni',
Žilavka = 'Žilavka',
Zweigelt = 'Zweigelt'
}
// retsina greek wine with pine essences should be considered a vermouth

38
src/utils/alcohol.ts Normal file

@ -0,0 +1,38 @@
import { SakeVolume, SpiritVolume, StandardDrink, WineVolume } from '../types'
import { roundToOneDecimal } from './'
export const alcoholToStandardDrinks = (
alcohol: number,
bottle: number
): StandardDrink => {
const UK100ml = roundToOneDecimal(10 * alcohol)
const AU100ml = roundToOneDecimal(7.91 * alcohol)
const US100ml = roundToOneDecimal(5.64 * alcohol)
const bottleMultiplier = bottle / 100
return {
'100ml': {
UK: UK100ml,
AU: AU100ml,
US: US100ml
},
bottle: {
UK: roundToOneDecimal(UK100ml * bottleMultiplier),
AU: roundToOneDecimal(AU100ml * bottleMultiplier),
US: roundToOneDecimal(US100ml * bottleMultiplier)
}
}
}
export const volumeToMl = (
volume: WineVolume | SpiritVolume | SakeVolume
): number => {
if (volume.endsWith('L')) {
const volumeMl = volume.replace('L', '')
return Number(volumeMl) * 1000
}
throw new Error('Not supported volume type')
}

8
src/utils/index.ts Normal file

@ -0,0 +1,8 @@
export * from './validation'
export * from './nostr'
export * from './route'
export * from './utils'
export * from './alcohol'
export * from './wine'
export * from './spirit'
export * from './sake'

34
src/utils/nostr.ts Normal file

@ -0,0 +1,34 @@
import { nip19 } from 'nostr-tools'
/**
* NPUB provided - it will convert NPUB to HEX
* HEX provided - it will return HEX
*
* @param pubKey in NPUB, HEX format
* @returns HEX format
*/
export const npubToHex = (pubKey: string): string | null => {
// If key is NPUB
if (pubKey.startsWith('npub1')) {
try {
return nip19.decode(pubKey).data as string
} catch (err) {
console.log(Error(`Error converting npub to hex. Error: ${err}`))
return null
}
}
// valid hex key
if (validateHex(pubKey)) return pubKey
// Not a valid hex key
return null
}
/**
* @param hexKey hex private or public key
* @returns whether or not is key valid
*/
export const validateHex = (hexKey: string) => {
return hexKey.match(/^[a-f0-9]{64}$/)
}

28
src/utils/route.ts Normal file

@ -0,0 +1,28 @@
import { Response } from 'express'
import { InsertOneResult } from 'mongodb'
export const handleReqError = (res: Response, error: unknown) => {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
} else if (typeof error === 'string') {
res.status(400).send(error)
}
}
export const handleReqSuccess = (
res: Response,
result: InsertOneResult<Document> | undefined,
itemName: string
) => {
if (result) {
res
.status(201)
.send(
`Successfully created a new ${itemName} with id ${result.insertedId}`
)
} else {
res.status(500).send(`Failed to create a new ${itemName}.`)
}
}

36
src/utils/sake.ts Normal file

@ -0,0 +1,36 @@
import {
SakeDesignation,
TableSakeDesignation,
PureSakeDesignation,
BlendedSakeDesignation,
SakePolishMin
} from '../types'
export const sakePolishMap:
| {
[key in SakeDesignation.Table]: {
[key in TableSakeDesignation]: SakePolishMin
}
}
| {
[key in SakeDesignation.Pure]: {
[key in PureSakeDesignation]: SakePolishMin
}
}
| {
[key in SakeDesignation.Blended]: {
[key in BlendedSakeDesignation]: SakePolishMin
}
} = {
[SakeDesignation.Table]: { [TableSakeDesignation.FutsūShu]: { min: 0 } },
[SakeDesignation.Pure]: {
[PureSakeDesignation.Junmai]: { min: 0.3 },
[PureSakeDesignation.JunmaiGinjo]: { min: 0.4 },
[PureSakeDesignation.JunmaiDaiginjo]: { min: 0.5 }
},
[SakeDesignation.Blended]: {
[BlendedSakeDesignation.Honjozo]: { min: 0.3 },
[BlendedSakeDesignation.Ginjo]: { min: 0.4 },
[BlendedSakeDesignation.Daiginjo]: { min: 0.5 }
}
}

251
src/utils/spirit.ts Normal file

@ -0,0 +1,251 @@
import {
SpiritCharacteristic,
SpiritType,
WhiteSpiritVariant,
DarkSpiritVariant,
LiqueursSpiritVariant
} from '../types'
// TODO: improve types
export const spiritVariantMap: {
[key in SpiritType]:
| {
[key in WhiteSpiritVariant]: (string | { [key: string]: string[] })[]
}
| {
[key in DarkSpiritVariant]: (string | { [key: string]: string[] })[]
}
| {
[key in LiqueursSpiritVariant]: (string | { [key: string]: string[] })[]
}
} = {
[SpiritType.White]: {
[WhiteSpiritVariant.Absinthe]: ['Blanche'],
[WhiteSpiritVariant.Pastis]: [
'Anise',
'Fennel',
'Licorice Root',
'Hyssop',
'Mint',
'Citrus Peel',
'Coriander Seeds',
'Angelica Root',
'Cinnamon',
'Clove'
],
[WhiteSpiritVariant.Vodka]: [
'Wheat',
'Rye',
'Corn',
'Potato',
'Barley',
'Sugarcane',
'Fruits',
'Grains'
],
[WhiteSpiritVariant.Genever]: [
{
Young: ['Juniper']
}
],
[WhiteSpiritVariant.Gin]: [
{
'London Dry': [
'Juniper',
'Coriander',
'Angelica root',
'Lemon peel',
'Orange peel',
'Orris root',
'Cassia bark',
'Licorice root',
'Grapefruit peel',
'Elderflower'
]
},
'Plymouth'
],
[WhiteSpiritVariant.Mezcal]: [
{ Joven: ['Espadín', 'Tepeztate', 'Tequilana (blue)', 'Tobalá'] }
],
[WhiteSpiritVariant.Rum]: ['Blanco', 'Cachaça', 'Platino', 'Agricole'],
[WhiteSpiritVariant.EauDeVie]: [
'Apple',
'Blackcurrant',
'Butterscotch',
'Peach',
'Pear',
'Plum',
'Raspberries'
],
[WhiteSpiritVariant.Grappa]: ['Marc', 'Pisco'],
[WhiteSpiritVariant.Baijiu]: [
'Sorghum',
'Wheat',
'Barley',
'Rice',
'Millet'
],
[WhiteSpiritVariant.Soju]: [
'Barley',
'Brown sugar',
'Buckwheat',
'Rice',
'Sweet Potato'
],
[WhiteSpiritVariant.Aquavit]: [],
[WhiteSpiritVariant.Arrack]: []
},
[SpiritType.Dark]: {
[DarkSpiritVariant.Absinthe]: ['Jaune', 'Verte'],
[DarkSpiritVariant.Brandy]: [
{
Grape: [
'VS',
'VSOP',
'XO',
'Beyond Age',
'Solera',
'Solera Reserva',
'Solera Gran Reserva'
]
}
],
[DarkSpiritVariant.Calvados]: ['Apple', 'Pear'],
[DarkSpiritVariant.Chartreuse]: ['Green', 'Yellow'],
[DarkSpiritVariant.Genever]: [{ Old: ['Juniper'] }, { Coren: ['Juniper'] }],
[DarkSpiritVariant.Mezcal]: ['Reposado', 'Abuelo', 'Añejo', 'Extra Añejo'],
[DarkSpiritVariant.Rum]: [
{
Sugar: [
'Cachaca (amarela/ouro)',
'Dark Rum',
'Gold Rum',
'Over-proof',
'Premium',
'Spiced'
]
}
],
[DarkSpiritVariant.Slivovitz]: [],
[DarkSpiritVariant.Whiskey]: [
'Barley',
'Rye',
'Wheat',
'Corn',
'Oat',
'Rice'
],
[DarkSpiritVariant.Arrack]: []
},
[SpiritType.Liqueurs]: {
[LiqueursSpiritVariant.Amaro]: [],
[LiqueursSpiritVariant.Coffee]: [],
[LiqueursSpiritVariant.Cream]: [
'Egg (Advocaat)',
'Rum',
'Strawberry',
'Whiskey (Baileys etc)'
],
[LiqueursSpiritVariant.Creme]: [
'Almond',
'Banana',
'Blackcurrant',
'Chocolate',
'Peach',
'Sour Cherry',
'Violet'
],
[LiqueursSpiritVariant.Flowers]: ['Rose', 'Violet', 'Elderflower'],
[LiqueursSpiritVariant.Fruit]: [
'Blackcurrant',
'Lemon',
'Melon',
'Orange',
'Peach',
'Plum',
'Raspberry',
'Yuzu'
],
[LiqueursSpiritVariant.Herb]: [
'Anise',
'Dom Benedictine',
'Bitters',
'Ginger',
'Jägermeister',
'Metaxa',
'Mint'
],
[LiqueursSpiritVariant.Honey]: ['Licor 43', 'Rum', 'Vodka', 'Whiskey'],
[LiqueursSpiritVariant.Nut]: [
'Almond',
'Apricot Kernel',
'Hazelnut',
'Peanut',
'Pecan',
'Walnut',
'Amarula'
]
}
}
export const spiritCharacteristicsMap: {
[key in SpiritCharacteristic]: string[]
} = {
[SpiritCharacteristic.LightAndNeutral]: [
WhiteSpiritVariant.Mezcal,
WhiteSpiritVariant.Soju,
WhiteSpiritVariant.Vodka,
WhiteSpiritVariant.Rum,
WhiteSpiritVariant.Aquavit
],
[SpiritCharacteristic.FruityAndAromatic]: [
WhiteSpiritVariant.Rum,
DarkSpiritVariant.Calvados,
WhiteSpiritVariant.EauDeVie,
LiqueursSpiritVariant.Fruit,
WhiteSpiritVariant.Gin,
WhiteSpiritVariant.Grappa
],
[SpiritCharacteristic.HerbalAndBotanical]: [
WhiteSpiritVariant.Absinthe,
DarkSpiritVariant.Absinthe,
LiqueursSpiritVariant.Amaro,
WhiteSpiritVariant.Genever,
DarkSpiritVariant.Genever,
WhiteSpiritVariant.Gin,
WhiteSpiritVariant.Pastis,
DarkSpiritVariant.Chartreuse,
WhiteSpiritVariant.Aquavit
],
[SpiritCharacteristic.SweetAndSyrupy]: [
DarkSpiritVariant.Brandy,
LiqueursSpiritVariant.Cream,
LiqueursSpiritVariant.Creme,
DarkSpiritVariant.Rum,
LiqueursSpiritVariant.Nut
],
[SpiritCharacteristic.SmokyAndSpicy]: [
WhiteSpiritVariant.Baijiu,
DarkSpiritVariant.Rum,
WhiteSpiritVariant.Gin,
WhiteSpiritVariant.EauDeVie,
DarkSpiritVariant.Mezcal,
DarkSpiritVariant.Whiskey,
LiqueursSpiritVariant.Cream,
LiqueursSpiritVariant.Honey,
WhiteSpiritVariant.Arrack,
DarkSpiritVariant.Arrack
],
[SpiritCharacteristic.RichAndFullBodied]: [
WhiteSpiritVariant.Baijiu,
DarkSpiritVariant.Brandy,
DarkSpiritVariant.Rum,
WhiteSpiritVariant.Grappa,
WhiteSpiritVariant.EauDeVie,
LiqueursSpiritVariant.Cream,
LiqueursSpiritVariant.Honey,
DarkSpiritVariant.Whiskey
]
}

8
src/utils/utils.ts Normal file

@ -0,0 +1,8 @@
export const roundToOneDecimal = (number: number) =>
Math.round(number * 10) / 10
export const isObject = (item: unknown) =>
typeof item === 'object' && !Array.isArray(item) && item !== null
export const compareArrays = (a: unknown[], b: unknown[]) =>
JSON.stringify(a.sort()) === JSON.stringify(b.sort())

@ -0,0 +1,9 @@
export * from './user'
export * from './nostr'
export * from './review'
export * from './review'
export * from './wine'
export * from './spirit'
export * from './product'
export * from './validations'
export * from './sake'

@ -0,0 +1,24 @@
import Joi from 'joi'
import { npubToHex, validateHex } from '../nostr'
export const nostrEventValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
id: Joi.string().required(),
kind: Joi.number().required(),
content: Joi.string().required(),
tags: Joi.array().items(Joi.array().items(Joi.string())),
created_at: Joi.number().required(),
pubkey: Joi.string()
.custom((value, helper) => {
const hex = npubToHex(value as string)
if (!hex || !validateHex(hex)) {
return helper.message({
custom: Joi.expression('"pubkey" contains an invalid value')
})
}
return value
})
.required()
}).validate(data)

@ -0,0 +1,46 @@
import { collections } from '../../services/database.service'
import { DBcollection, ProductType } from '../../types'
export const productCodeValidation = async (
ean: string,
upc: string,
sku: string,
collection: DBcollection,
productType: ProductType
) => {
if (!ean && !upc && !sku) {
throw new Error(
'provide "productCodeEAN", "productCodeUPC" or "productCodeSKU"'
)
}
if (ean) {
const existingProduct = await collections[collection]?.findOne({
productCodeEAN: ean
})
if (existingProduct) {
throw new Error(`${productType} with provided "productCodeEAN" exists`)
}
}
if (upc) {
const existingProduct = await collections[collection]?.findOne({
productCodeUPC: upc
})
if (existingProduct) {
throw new Error(`${productType} with provided "productCodeUPC" exists`)
}
}
if (sku) {
const existingProduct = await collections[collection]?.findOne({
productCodeSKU: sku
})
if (existingProduct) {
throw new Error(`${productType} with provided "productCodeSKU" exists`)
}
}
}

@ -0,0 +1,773 @@
import Joi from 'joi'
import {
RatingOption,
TastingNote,
TastingNoteKey,
VisualAssessmentKey,
ClarityVisualAssessment,
NatureVisualAssessment,
WhiteColour,
AmberColour,
RoseColour,
RedColour,
BlueColour,
GreenColour,
ProductType,
WineColour,
SakeColour,
SpiritColour,
PrimaryFlavoursAndAromasKey,
Condition,
Intensity,
Age,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
BlackFruit,
ChocolateFruit,
TropicalFruit,
MelonFruit,
Floral,
Vegetal,
Earth,
Microbial,
Oak,
Chocolate,
Oxidation,
Umami,
Balsamic,
Grain,
Dairy,
Anisoles,
Brettanomyces,
VolatileAcidity,
Reduction,
TextureAndBalanceKey,
Sweetness,
Concentration,
TanninType,
RipeTannin,
UnripeTannin,
Body,
FlavourIntensity,
PalateLength,
ReasoningKey,
ReasoningConcentration,
Quality,
ReadinessToDrink,
RequiredPrimaryFlavoursAndAromasKey,
TanninString,
TanninObject
} from '../../types'
import { compareArrays, isObject } from '../utils'
import { producerIdValidation, validateStringValue } from './'
export const reviewValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
eventId: Joi.string().required(),
productId: producerIdValidation,
productType: Joi.string()
.valid(...Object.values(ProductType))
.required(),
rating: Joi.alternatives()
.try(
Joi.string().valid(...Object.values(RatingOption)),
Joi.number().min(84).max(100)
)
.required(),
reviewText: Joi.string().required(),
tastingNote: Joi.object()
.custom((tastingNote: TastingNote, helper) => {
const message = (str: string) =>
helper.message({
custom: Joi.expression(str)
})
/**
* Root keys validation
*/
const tastingNoteKeys = Object.keys(tastingNote)
const validTastingNoteKeys = Object.values(TastingNoteKey)
if (!compareArrays(tastingNoteKeys, validTastingNoteKeys)) {
return message(
`provided "tastingNote" is not valid. "tastingNote" object has to include the following keys: [${validTastingNoteKeys.join(', ')}]`
)
}
/**
* visualAssessment validation
*/
const visualAssessmentKeys = Object.keys(
tastingNote[TastingNoteKey.VisualAssessment]
)
const validVisualAssessmentKeys = Object.values(VisualAssessmentKey)
if (!compareArrays(visualAssessmentKeys, validVisualAssessmentKeys)) {
return message(
`provided "tastingNote" is not valid. "visualAssessment" object has to include the following keys: [${validVisualAssessmentKeys.join(', ')}]`
)
}
const messageWithValidOptions = (
noteKey: TastingNoteKey,
noteSubKey:
| VisualAssessmentKey
| PrimaryFlavoursAndAromasKey
| TextureAndBalanceKey,
options: object,
product?: string
) =>
message(
`provided "tastingNote" is not valid. Valid options for "${[noteKey, noteSubKey].join('-')}"${product ? ` for "${product}"` : ''} are: [${Object.values(options).join(', ')}]`
)
/**
* visualAssessment-clarity validation
*/
const clarity =
tastingNote[TastingNoteKey.VisualAssessment][
VisualAssessmentKey.Clarity
]
if (validateStringValue(clarity, ClarityVisualAssessment)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Clarity,
ClarityVisualAssessment
)
}
/**
* visualAssessment-nature validation
*/
const nature =
tastingNote[TastingNoteKey.VisualAssessment][
VisualAssessmentKey.Nature
]
if (validateStringValue(nature, NatureVisualAssessment)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Nature,
NatureVisualAssessment
)
}
/**
* visualAssessment-colour validation
*/
const colour =
tastingNote[TastingNoteKey.VisualAssessment][
VisualAssessmentKey.Colour
]
const validColorOptions = {
...WhiteColour,
...AmberColour,
...RoseColour,
...RedColour,
...BlueColour,
...GreenColour
}
if (validateStringValue(colour, validColorOptions)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Colour,
validColorOptions
)
}
// check if colour is applicable to the product
const productType: ProductType = helper.state.ancestors[0].productType
switch (productType) {
case ProductType.Wine:
{
if (validateStringValue(colour, WineColour)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Colour,
WineColour,
productType
)
}
}
break
case ProductType.Sake:
{
if (validateStringValue(colour, SakeColour)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Colour,
SakeColour,
productType
)
}
}
break
case ProductType.Spirit:
{
if (validateStringValue(colour, SpiritColour)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Colour,
SpiritColour,
productType
)
}
}
break
default:
break
}
/**
* primaryFlavoursAndAromas validation
*/
const primaryFlavoursAndAromasKeys = Object.keys(
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas]
)
const validPrimaryFlavoursAndAromasKeys = Object.values(
PrimaryFlavoursAndAromasKey
)
const requiredPrimaryFlavoursAndAromasKeys = Object.values(
RequiredPrimaryFlavoursAndAromasKey
)
for (const requiredKey of requiredPrimaryFlavoursAndAromasKeys) {
if (!primaryFlavoursAndAromasKeys.includes(requiredKey)) {
return message(
`provided "tastingNote" is not valid. "visualAssessment-primaryFlavoursAndAromas" object has to include the following keys: [${requiredPrimaryFlavoursAndAromasKeys.join(', ')}]`
)
}
}
for (const key of primaryFlavoursAndAromasKeys) {
if (
!validPrimaryFlavoursAndAromasKeys.includes(
key as PrimaryFlavoursAndAromasKey
)
) {
return message(
`provided "tastingNote" is not valid. "${key}" is not a valid key for "visualAssessment-primaryFlavoursAndAromas" object, valid keys are: [${validPrimaryFlavoursAndAromasKeys.join(', ')}]`
)
}
}
// primaryFlavoursAndAromas-condition validation
const condition =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Condition
]
if (validateStringValue(condition, Condition)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Condition,
Condition
)
}
// primaryFlavoursAndAromas-intensity validation
const intensity =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Intensity
]
if (validateStringValue(intensity, Intensity)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Intensity,
Intensity
)
}
// primaryFlavoursAndAromas-age validation
const age =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Age
]
if (validateStringValue(age, Age)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Age,
Age
)
}
// primaryFlavoursAndAromas-fruit validation
const fruit =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Fruit
]
const validFruitOptions = {
...CitrusFruit,
...AppleFruit,
...StoneFruit,
...RedFruit,
...BlackFruit,
...ChocolateFruit,
...TropicalFruit,
...MelonFruit
}
if (fruit && validateStringValue(fruit, validFruitOptions)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Fruit,
validFruitOptions
)
}
// primaryFlavoursAndAromas-floral validation
const floral =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Floral
]
if (floral && validateStringValue(floral, Floral)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Floral,
Floral
)
}
// primaryFlavoursAndAromas-vegetal validation
const vegetal =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Vegetal
]
if (vegetal && validateStringValue(vegetal, Vegetal)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Vegetal,
Vegetal
)
}
// primaryFlavoursAndAromas-earth validation
const earth =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Earth
]
if (earth && validateStringValue(earth, Earth)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Earth,
Earth
)
}
// primaryFlavoursAndAromas-earth validation
const microbial =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Microbial
]
if (microbial && validateStringValue(microbial, Microbial)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Microbial,
Microbial
)
}
// primaryFlavoursAndAromas-oak validation
const oak =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Oak
]
if (oak && validateStringValue(oak, Oak)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Oak,
Oak
)
}
// primaryFlavoursAndAromas-chocolate validation
const chocolate =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Chocolate
]
if (chocolate && validateStringValue(chocolate, Chocolate)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Chocolate,
Chocolate
)
}
// primaryFlavoursAndAromas-oxidation validation
const oxidation =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Oxidation
]
if (oxidation && validateStringValue(oxidation, Oxidation)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Oxidation,
Oxidation
)
}
// primaryFlavoursAndAromas-umami validation
const umami =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Umami
]
if (umami && validateStringValue(umami, Umami)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Umami,
Umami
)
}
// primaryFlavoursAndAromas-balsamic validation
const balsamic =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Balsamic
]
if (balsamic && validateStringValue(balsamic, Balsamic)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Balsamic,
Balsamic
)
}
// primaryFlavoursAndAromas-grain validation
const grain =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Grain
]
if (grain && validateStringValue(grain, Grain)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Grain,
Grain
)
}
// primaryFlavoursAndAromas-dairy validation
const dairy =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Dairy
]
if (dairy && validateStringValue(dairy, Dairy)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Dairy,
Dairy
)
}
// primaryFlavoursAndAromas-faults validation
const faults =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas][
PrimaryFlavoursAndAromasKey.Faults
]
const validFaultsOptions = {
...Anisoles,
...Brettanomyces,
...VolatileAcidity,
...Reduction
}
if (faults && validateStringValue(faults, validFaultsOptions)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Faults,
validFaultsOptions
)
}
/**
* textureAndBalance validation
*/
const textureAndBalanceKeys = Object.keys(
tastingNote[TastingNoteKey.TextureAndBalance]
)
const validTextureAndBalanceKeys = Object.values(
TextureAndBalanceKey
).filter((key) => key !== TextureAndBalanceKey.Age)
if (!compareArrays(textureAndBalanceKeys, validTextureAndBalanceKeys)) {
return message(
`provided "tastingNote" is not valid. "visualAssessment-textureAndBalance" object has to include the following keys: [${validTextureAndBalanceKeys.join(', ')}]`
)
}
// textureAndBalance-sweetness validation
const sweetness =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Sweetness
]
if (validateStringValue(sweetness, Sweetness)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Sweetness,
Sweetness
)
}
// textureAndBalance-acidity validation
const acidity =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Acidity
]
if (validateStringValue(acidity, Concentration)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Acidity,
Concentration
)
}
// textureAndBalance-tannin validation
const tannin =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Tannin
]
const tanninKeys = Object.keys(tannin)
const tanninKey = tanninKeys[0] as Concentration
const validTanninKeys = Object.values(Concentration)
if (typeof tannin === 'string') {
if (validateStringValue(tannin, TanninString)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Tannin,
TanninString
)
}
} else {
if (!isObject(tannin)) {
return message(
`provided "tastingNote" is not valid. "textureAndBalance-tannin" should be an object with the following properties: [${validTanninKeys.join(', ')}]`
)
}
if (tanninKeys.length !== 1 || !validTanninKeys.includes(tanninKey)) {
return message(
`provided "tastingNote" is not valid. Valid options for "textureAndBalance-tannin" are: [${validTanninKeys.join(', ')}]`
)
}
// textureAndBalance-tannin-type validation
const tanninType = (
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Tannin
] as TanninObject
)[tanninKey]
const tanninTypeKeys = Object.keys(tanninType)
const tanninTypeKey = tanninTypeKeys[0] as TanninType
const validTanninTypeKeys = Object.values(TanninType)
if (!isObject(tanninType)) {
return message(
`provided "tastingNote" is not valid. "textureAndBalance-tannin-type" should be an object with the following properties: [${validTanninTypeKeys.join(', ')}]`
)
}
if (
tanninTypeKeys.length !== 1 ||
!validTanninTypeKeys.includes(tanninTypeKey)
) {
return message(
`provided "tastingNote" is not valid. Valid options for "textureAndBalance-tannin-type" are: [${validTanninTypeKeys.join(', ')}]`
)
}
// textureAndBalance-tannin-value validation
const tanninValue = (
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Tannin
] as TanninObject
)[tanninKey][tanninTypeKey]
const validTanninValueOptions = { ...RipeTannin, ...UnripeTannin }
if (validateStringValue(tanninValue, validTanninValueOptions)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Tannin,
validTanninValueOptions
)
}
}
// textureAndBalance-alcohol validation
const alcohol =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Alcohol
]
if (validateStringValue(alcohol, Concentration)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Alcohol,
Concentration
)
}
// textureAndBalance-body validation
const body =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Body
]
if (validateStringValue(body, Body)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Body,
Body
)
}
// textureAndBalance-flavourIntensity validation
const flavourIntensity =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.FlavourIntensity
]
if (validateStringValue(flavourIntensity, FlavourIntensity)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.FlavourIntensity,
FlavourIntensity
)
}
// textureAndBalance-palateLength validation
const palateLength =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.PalateLength
]
if (validateStringValue(palateLength, PalateLength)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.PalateLength,
PalateLength
)
}
// textureAndBalance-reasoning validation
const reasoning =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Reasoning
]
const reasoningKeys = Object.keys(reasoning)
const validReasoningKeys = Object.values(ReasoningKey)
if (!isObject(reasoning)) {
return message(
`provided "tastingNote" is not valid. "textureAndBalance-reasoning" should be an object with the following properties: [${validReasoningKeys.join(', ')}]`
)
}
if (!compareArrays(reasoningKeys, validReasoningKeys)) {
return message(
`provided "tastingNote" is not valid. Valid options for "textureAndBalance-reasoning" are: [${validReasoningKeys.join(', ')}]`
)
}
// textureAndBalance-reasoning-balance validation
const reasoningBalance =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Reasoning
][ReasoningKey.Balance]
if (typeof reasoningBalance !== 'boolean') {
return message(
`provided "tastingNote" is not valid. "textureAndBalance-reasoning-balance" should be a boolean`
)
}
// textureAndBalance-reasoning-concentration validation
const reasoningConcentration =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Reasoning
][ReasoningKey.Concentration]
const validReasoningConcentrationOptions = Object.values(
ReasoningConcentration
)
if (
typeof reasoningConcentration !== 'string' ||
!validReasoningConcentrationOptions.includes(reasoningConcentration)
) {
return message(
`provided "tastingNote" is not valid. Valid options for "textureAndBalance-reasoning-concentration" are: [${validReasoningConcentrationOptions.join(', ')}]`
)
}
// textureAndBalance-reasoning-complex validation
const reasoningComplex =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Reasoning
][ReasoningKey.Complex]
if (
typeof reasoningComplex !== 'boolean' ||
!validReasoningConcentrationOptions.includes(reasoningConcentration)
) {
return message(
`provided "tastingNote" is not valid. "textureAndBalance-reasoning-complex" should be a boolean`
)
}
// textureAndBalance-quality validation
const quality =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.Quality
]
if (validateStringValue(quality, Quality)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Quality,
Quality
)
}
// textureAndBalance-readinessToDrink validation
const readinessToDrink =
tastingNote[TastingNoteKey.TextureAndBalance][
TextureAndBalanceKey.ReadinessToDrink
]
if (validateStringValue(readinessToDrink, ReadinessToDrink)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.ReadinessToDrink,
ReadinessToDrink
)
}
return tastingNote
})
.required()
}).validate(data)

@ -0,0 +1,137 @@
import Joi from 'joi'
import {
SakeDesignation,
SakeCharacteristic,
SakeVolume,
SakeStarter,
RiceVarietal,
SakePolishMin,
SakeYeastStrain,
SakeKoji
} from '../../types'
import {
vintageValidation,
productCodeEANvalidation,
productCodeUPCvalidation,
productCodeSKUvalidation,
countryValidation,
nameValidation,
producerIdValidation,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imageValidation
} from './'
import { sakePolishMap } from '../'
export const sakeValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
region: Joi.string(),
name: nameValidation,
producerId: producerIdValidation,
designation: Joi.object()
.custom((designation: { [key in SakeDesignation]: string }, helper) => {
const descriptionKeys = Object.keys(designation)
if (descriptionKeys.length !== 1) {
return helper.message({
custom: Joi.expression(`provide designation is not valid.`)
})
}
const designationKey = descriptionKeys[0]
const designationKeyOptions: string[] = Object.values(SakeDesignation)
if (!designationKeyOptions.includes(designationKey)) {
return helper.message({
custom: Joi.expression(
`provide designation key "${designationKey}" is not valid. Valid options are [${designationKeyOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
const descriptionValues = Object.values(designation)
if (descriptionValues.length !== 1) {
return helper.message({
custom: Joi.expression(`provide designation is not valid.`)
})
}
const descriptionValue = descriptionValues[0]
if (typeof descriptionValue !== 'string') {
return helper.message({
custom: Joi.expression(`provide designation is not valid.`)
})
}
const designationValueOptions: string[] = Object.keys(
(sakePolishMap as { [key: string]: { [key: string]: unknown } })[
designationKey
]
)
if (!designationValueOptions.includes(descriptionValue)) {
return helper.message({
custom: Joi.expression(
`provide designation value "${descriptionValue}" is not valid. Valid options are [${designationValueOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
return designation
})
.required(),
polishRate: Joi.number().custom((polishRate, helper) => {
// return if no state ancestors
if (!helper.state.ancestors) {
return polishRate
}
const designation = helper.state.ancestors[0].designation
const designationKey = Object.keys(designation)[0]
const designationValue: string = Object.values(
designation as { [key: string]: string }
)[0]
const minPolishRate: number = (
sakePolishMap as { [key: string]: { [key: string]: SakePolishMin } }
)[designationKey][designationValue].min
if (polishRate < minPolishRate || polishRate > 0.99) {
return helper.message({
custom: Joi.expression(
`provide polishRate "${polishRate}" is not valid for "${designationKey} -> ${designationValue}". Valid range is "${minPolishRate} - 0.99"`
)
})
}
return polishRate
}),
characteristic: Joi.string()
.valid(...Object.values(SakeCharacteristic))
.required(),
starter: Joi.string().valid(...Object.values(SakeStarter)),
yeastStrain: Joi.string().valid(...Object.values(SakeYeastStrain)),
volume: volumeValidation(SakeVolume),
alcohol: alcoholValidation,
riceVarietal: Joi.array()
.items(Joi.string().valid(...Object.values(RiceVarietal)))
.required(),
koji: Joi.string()
.valid(...Object.values(SakeKoji))
.required(),
vintage: vintageValidation,
RRPamount: RRPamountValidation,
RRPcurrency: RRPcurrencyValidation,
description: descriptionValidation,
url: urlValidation,
image: imageValidation
}).validate(data)

@ -0,0 +1,293 @@
import Joi from 'joi'
import {
Ingredient,
SpiritType,
SpiritVolume,
SpiritCharacteristic
} from '../../types'
import { isObject, spiritVariantMap, spiritCharacteristicsMap } from '../'
import {
vintageValidation,
productCodeEANvalidation,
productCodeUPCvalidation,
productCodeSKUvalidation,
countryValidation,
nameValidation,
typeValidation,
producerIdValidation,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imageValidation
} from './'
export const spiritValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
region: Joi.string(),
name: nameValidation,
producerId: producerIdValidation,
type: typeValidation(SpiritType),
variant: Joi.alternatives().try(
Joi.string().custom((variant, helper) => {
// return if no value
if (!variant) {
return variant
}
// return if no state ancestors
if (!helper.state.ancestors) {
return variant
}
const spiritType: SpiritType = helper.state.ancestors[0].type
// return if no spiritType
if (!spiritType) {
return variant
}
const options: string[] = Object.keys(spiritVariantMap[spiritType])
if (!options.length) {
return helper.message({
custom: Joi.expression(
`no variants found for provided type of spirit`
)
})
}
if (!options.includes(variant)) {
return helper.message({
custom: Joi.expression(
`"${variant}" is not a valid variant for "${spiritType}" spirit. Valid options are [${options.map((option) => `"${option}"`).join(', ')}]`
)
})
}
return variant
}),
Joi.object().custom(
(
variant: { [key: string]: string | { [key: string]: string } },
helper
) => {
// return if no value
if (!variant) {
return variant
}
// return if multiple variant provided
if (Object.keys(variant).length !== 1) {
return helper.message({
custom: Joi.expression(`multiple variants provided`)
})
}
// return if no state ancestors
if (!helper.state.ancestors) {
return variant
}
const spiritType: SpiritType = helper.state.ancestors[0].type
// return if no spiritType
if (!spiritType) {
return variant
}
/**
* Variant
*/
const variantOptions: string[] = Object.keys(
spiritVariantMap[spiritType]
)
if (!variantOptions.length) {
return helper.message({
custom: Joi.expression(
`no variants found for provided type of spirit`
)
})
}
const variantName = Object.keys(variant)[0]
if (!variantOptions.includes(variantName)) {
return helper.message({
custom: Joi.expression(
`"${variantName}" is not a valid variant for "${spiritType}" spirit. Valid options are [${variantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
/**
* SubVariant
*/
const subVariants: (string | { [key: string]: string[] })[] = (
spiritVariantMap[spiritType] as {
[key: string]: (string | { [key: string]: string[] })[]
}
)[variantName]
const subVariantOptions: string[] =
typeof subVariants[0] === 'string'
? (subVariants as string[])
: subVariants.map((subVariant) => Object.keys(subVariant)[0])
const subVariantValues = Object.values(variant)
if (subVariantValues.length !== 1) {
return helper.message({
custom: Joi.expression(
`"${variantName}" is not a valid variant for "${spiritType}" spirit. Valid options are [${variantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
const subVariant = subVariantValues[0]
if (
typeof subVariant === 'string' &&
!subVariantOptions.includes(subVariant)
) {
return helper.message({
custom: Joi.expression(
`"${subVariant}" is not a valid variant for "${spiritType} -> ${variantName}" spirit. Valid options are [${subVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
} else if (isObject(subVariant)) {
const providedSubVariants = Object.keys(subVariant)
if (providedSubVariants.length !== 1) {
return helper.message({
custom: Joi.expression(
`"${subVariant}" is not a valid variant for "${spiritType} -> ${variantName}" spirit. Valid options are [${subVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
const subVariantName = providedSubVariants[0]
if (!subVariantOptions.includes(subVariantName)) {
return helper.message({
custom: Joi.expression(
`"${subVariantName}" is not a valid variant for "${spiritType} -> ${variantName}" spirit. Valid options are [${subVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
/**
* SubSubVariant
*/
if (typeof subVariants[0] === 'string') {
return helper.message({
custom: Joi.expression(
`Provided not valid variant for "${spiritType} -> ${variantName} -> ${subVariantName}" spirit. Valid options are [${subVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
const subSubVariantOption = (
subVariants as { [key: string]: string[] }[]
).find((subVariant) => subVariant[subVariantName] !== undefined)
if (!subSubVariantOption) {
return helper.message({
custom: Joi.expression(
`"${subVariantName}" is not a valid variant for "${spiritType} -> ${variantName} -> ${subVariantName}" spirit. Valid options are [${subVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
const subSubVariantOptions: string[] =
subSubVariantOption[subVariantName]
const providedSubSubVariant = Object.values(subVariant)[0]
if (typeof providedSubSubVariant !== 'string') {
return helper.message({
custom: Joi.expression(
`Provided variant is not valid for "${spiritType} -> ${variantName} -> ${subVariantName}" spirit. Valid options are [${subSubVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
if (!subSubVariantOptions.includes(providedSubSubVariant)) {
return helper.message({
custom: Joi.expression(
`"${providedSubSubVariant}" is not a valid variant for "${spiritType} -> ${variantName} -> ${subVariantName}" spirit. Valid options are [${subSubVariantOptions.map((option) => `"${option}"`).join(', ')}]`
)
})
}
}
return variant
}
)
),
characteristic: Joi.string()
.custom((characteristic, helper) => {
if (!Object.values(SpiritCharacteristic).includes(characteristic)) {
return helper.message({
custom: Joi.expression(
`"${characteristic}" is not a valid characteristic. Valid options are [${Object.values(
SpiritCharacteristic
)
.map((option) => `"${option}"`)
.join(', ')}]`
)
})
}
const spiritType: SpiritType = helper.state.ancestors[0].type
const spiritVariant: string | { [key: string]: unknown } =
helper.state.ancestors[0].variant
const spiritVariantName =
typeof spiritVariant === 'string'
? spiritVariant
: Object.keys(spiritVariant)[0]
const variantsInCharacteristic =
spiritCharacteristicsMap[characteristic as SpiritCharacteristic]
const characteristicsInVariant = Object.keys(
spiritCharacteristicsMap
).filter((char) =>
spiritCharacteristicsMap[char as SpiritCharacteristic].includes(
spiritVariantName
)
)
if (!variantsInCharacteristic.includes(spiritVariantName)) {
return helper.message({
custom: Joi.expression(
`"${characteristic}" is not a valid characteristic for "${spiritType} -> ${spiritVariantName}". Valid options are [${characteristicsInVariant
.map((option) => `"${option}"`)
.join(', ')}]`
)
})
}
return characteristic
})
.required(),
ingredients: Joi.array()
.items(Joi.string().valid(...Object.values(Ingredient)))
.required(),
volume: volumeValidation(SpiritVolume),
alcohol: alcoholValidation,
vintage: vintageValidation,
RRPamount: RRPamountValidation,
RRPcurrency: RRPcurrencyValidation,
description: descriptionValidation,
url: urlValidation,
image: imageValidation
}).validate(data)

@ -0,0 +1,24 @@
import Joi from 'joi'
import { UserRole } from '../../types'
import { npubToHex, validateHex } from '../nostr'
export const userValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
name: Joi.string().required(),
npub: Joi.string()
.custom((value, helper) => {
const hex = npubToHex(value as string)
if (!hex || !validateHex(hex)) {
return helper.message({
custom: Joi.expression('"npub" contains an invalid value')
})
}
return hex
})
.required(),
role: Joi.string()
.valid(...Object.values(UserRole))
.required()
}).validate(data)

@ -0,0 +1,43 @@
import Joi from 'joi'
import { VintageOption } from '../../types'
export const vintageValidation = Joi.alternatives()
.try(
Joi.string().valid(...Object.values(VintageOption)),
Joi.number().min(1700).max(new Date().getFullYear())
)
.required()
export const productCodeEANvalidation = Joi.string().allow('').required()
export const productCodeUPCvalidation = Joi.string().allow('').required()
export const productCodeSKUvalidation = Joi.string().allow('').required()
export const countryValidation = Joi.string().length(2)
export const nameValidation = Joi.string().required()
export const typeValidation = (typeEnum: { [key: string]: string }) =>
Joi.string()
.valid(...Object.values(typeEnum))
.required()
export const producerIdValidation = Joi.string().length(24).required()
export const volumeValidation = (volumeEnum: { [key: string]: string }) =>
Joi.string()
.valid(...Object.values(volumeEnum))
.required()
export const alcoholValidation = Joi.number().min(0).max(0.99).required()
export const RRPamountValidation = Joi.number().required()
export const RRPcurrencyValidation = Joi.string().length(3).required()
export const descriptionValidation = Joi.string().required()
export const urlValidation = Joi.string()
export const imageValidation = Joi.string()
export const validateStringValue = (
value: unknown,
validOptions: { [key: string]: string }
) => typeof value !== 'string' || !Object.values(validOptions).includes(value)

@ -0,0 +1,393 @@
import Joi from 'joi'
import {
WineType,
BottleClosure,
Viticulture,
WineRegion,
WineVolume,
WineStyle,
WhiteWineCharacteristic,
AmberWineCharacteristic,
RoseWineCharacteristic,
RedWineCharacteristic,
GrapeVarietal
} from '../../types'
import { wineRegionsMap, isObject } from '../'
import {
vintageValidation,
productCodeEANvalidation,
productCodeUPCvalidation,
productCodeSKUvalidation,
countryValidation,
nameValidation,
typeValidation,
producerIdValidation,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imageValidation
} from './'
export const wineValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
// TODO: improve types
region: Joi.alternatives()
.try(
Joi.string().custom((value, helper) => {
if (value) {
if (helper.state.ancestors) {
const { country } = helper.state.ancestors[0]
if (country) {
const regionMap = wineRegionsMap[country]
if (regionMap) {
const regions = Array.isArray(regionMap)
? regionMap
: Object.keys(regionMap)
if (!regions.includes(value)) {
return helper.message({
custom: Joi.expression(
`"region" contains an invalid value. Valid values for ${country} are [${regions.join(', ')}]`
)
})
}
}
}
}
}
return value
}),
Joi.object().custom(
(
value: {
[key: string]:
| string
| { [key: string]: string | { [key: string]: string } }
},
helper: Joi.CustomHelpers<unknown>
) => {
// return if no value
if (!value) {
return value
}
// return if no state ancestors
if (!helper.state.ancestors) {
return value
}
const country: string = helper.state.ancestors[0].country
// return if no country
if (!country) {
return value
}
const regionMap = wineRegionsMap[country]
// return if no region map
if (!regionMap) {
return value
}
/**
* Region
*/
// list of supported regions
const regions = Array.isArray(regionMap)
? regionMap
: Object.keys(regionMap)
const providedRegions = Object.keys(value)
// check if multiple regions provided
if (providedRegions.length !== 1) {
return helper.message({
custom: Joi.expression(
`"region" contains an invalid value. Valid values a single string or a region object`
)
})
}
const returnCustomMessage = (
map: string[],
options: string[]
): Joi.ErrorReport =>
helper.message({
custom: Joi.expression(
options.length
? `"region" contains an invalid value. Valid values for "${map.join(' -> ')}" are [${options.map((option) => `"${option}"`).join(', ')}]`
: `"region" contains an invalid value. "${map.join(' -> ')}" does not have nested options`
)
})
const providedRegion = providedRegions[0]
// check if provided region is in list of supported regions
if (!regions.includes(providedRegion)) {
return returnCustomMessage([country], regions)
}
/**
* Subregion
*/
// list of supported subregions
const subRegions: string[] = Array.isArray(
(regionMap as WineRegion)[providedRegion]
)
? ((regionMap as WineRegion)[providedRegion] as string[])
: Object.keys((regionMap as WineRegion)[providedRegion])
const providedSubRegion:
| string
| { [key: string]: string | { [key: string]: string } } =
value[providedRegion]
// return if provided subregion is not a string or an object
if (
typeof providedSubRegion !== 'string' &&
!isObject(providedSubRegion)
) {
return returnCustomMessage([country, providedRegion], subRegions)
}
// if providedSubRegion is a string, check if it is in the list of supported subregions
if (
typeof providedSubRegion === 'string' &&
!subRegions.includes(providedSubRegion)
) {
return returnCustomMessage([country, providedRegion], subRegions)
}
// if providedSubRegion is an object, check if it is in the list of supported subregions
else if (isObject(providedSubRegion)) {
const providedSubRegions = Object.keys(providedSubRegion)
const providedSubRegionName: string = providedSubRegions[0]
// return if provided multiple subregions or if provided subregion is not in the supported list
if (
providedSubRegions.length !== 1 ||
!subRegions.includes(providedSubRegionName)
) {
return returnCustomMessage(
[country, providedRegion],
subRegions
)
}
/**
* Village
*/
const villageMap = (
(regionMap as WineRegion)[providedRegion] as {
[key: string]: string | { [key: string]: string[] }
}
)[providedSubRegionName]
if (villageMap === undefined) {
return helper.message({
custom: Joi.expression(
`"region" contains an invalid value. "${[country, providedRegion, providedSubRegionName].join(' -> ')}" does not have villages"`
)
})
}
// list of supported villages
const villages: string[] = Array.isArray(villageMap)
? (villageMap as unknown as string[])
: Object.keys(villageMap)
const providedVillage: string | { [key: string]: string } = (
value[providedRegion] as {
[key: string]: string
}
)[providedSubRegionName]
// return if provided village is not a string or an object
if (
typeof providedVillage !== 'string' &&
!isObject(providedVillage)
) {
return returnCustomMessage(
[country, providedRegion, providedSubRegionName],
subRegions
)
}
// if village is a string, check if it is in the supported list
if (
typeof providedVillage === 'string' &&
!villages.includes(providedVillage)
) {
return returnCustomMessage(
[country, providedRegion, providedSubRegionName],
villages
)
}
// if providedVillage is an object, check if it is in the supported list
else if (isObject(providedVillage)) {
const providedVillages = Object.keys(providedVillage)
const providedVillageName: string = providedVillages[0]
// return if provided multiple villages or if provided village is not in the supported list
if (
providedVillages.length !== 1 ||
!villages.includes(providedVillageName)
) {
return returnCustomMessage(
[country, providedRegion, providedSubRegionName],
villages
)
}
/**
* Vineyard
*/
// list of supported vineyards
const vineyards: string[] = (
(regionMap as WineRegion)[providedRegion] as {
[key: string]: { [key: string]: string[] }
}
)[providedSubRegionName][providedVillageName]
if (!vineyards) {
return helper.message({
custom: Joi.expression(
`"region" contains an invalid value. "${[country, providedRegion, providedSubRegionName, providedVillageName].join(' -> ')}" does not have vineyards"`
)
})
}
const providedVineyard: string = (
value[providedRegion] as {
[key: string]: {
[key: string]: string
}
}
)[providedSubRegionName][providedVillageName]
// check if provided vineyard is in the supported list
if (!vineyards.includes(providedVineyard)) {
return returnCustomMessage(
[
country,
providedRegion,
providedSubRegionName,
providedVillageName
],
vineyards
)
}
}
}
return value
}
)
)
.allow('')
.required(),
name: nameValidation,
producerId: producerIdValidation,
type: typeValidation(WineType),
style: Joi.string()
.valid(...Object.values(WineStyle))
.required(),
characteristic: Joi.string().custom((value, helper) => {
// return if no value
if (!value) {
return value
}
// return if no state ancestors
if (!helper.state.ancestors) {
return value
}
const wineType: WineType = helper.state.ancestors[0].type
// return if no wineType
if (!wineType) {
return value
}
let options: string[] = []
switch (wineType) {
case WineType.White:
{
options = Object.values(WhiteWineCharacteristic)
}
break
case WineType.Amber:
{
options = Object.values(AmberWineCharacteristic)
}
break
case WineType.Rose:
{
options = Object.values(RoseWineCharacteristic)
}
break
case WineType.Red:
{
options = Object.values(RedWineCharacteristic)
}
break
default:
break
}
if (!options.length) {
return helper.message({
custom: Joi.expression(
`no characteristics found for provided type of wine`
)
})
}
if (!options.includes(value)) {
return helper.message({
custom: Joi.expression(
`"${value}" is not a valid characteristic for "${wineType}" wine. Valid options are [${options.map((option) => `"${option}"`).join(', ')}]`
)
})
}
return value
}),
volume: volumeValidation(WineVolume),
alcohol: alcoholValidation,
grapeVarietal: Joi.array()
.items(Joi.string().valid(...Object.values(GrapeVarietal)))
.required(),
vintage: vintageValidation,
viticulture: Joi.string()
.valid(...Object.values(Viticulture))
.required(),
sulfites: Joi.number().min(0).max(400),
filtered: Joi.boolean().required(),
vegan: Joi.boolean().required(),
kosher: Joi.boolean().required(),
closure: Joi.string()
.valid(...Object.values(BottleClosure))
.required(),
RRPamount: RRPamountValidation,
RRPcurrency: RRPcurrencyValidation,
description: descriptionValidation,
url: urlValidation,
image: imageValidation
}).validate(data)

689
src/utils/wine.ts Normal file

@ -0,0 +1,689 @@
import { WineRegion } from '../types'
export const wineRegionsMap: { [key: string]: string[] | WineRegion } = {
IT: {
Piedmont: {
Langhe: {
Barolo: ['La Morra', 'Castiglione Falletto', `Serralunga d'Alba`],
Barbaresco: ['Neive', 'Alba', 'Treiso']
},
Monferrato: ['Gavi', 'Asti', 'Canelli']
},
Tuscany: [
'Chianti',
'Montalcino',
'Montepulciano',
'Scansano',
'Carmignano'
],
Veneto: ['Valpolicella', 'Soave'],
'Friuli-Venezia Giulia': [
'Collio',
'Colli Orientali del Friuli',
'Friuli Grave del Friuli'
],
'Trentino-Alto Adige': ['Trentino', 'Alto Adige'],
Lombardy: [],
Franciacorta: [],
Valtellina: [],
'Emilia-Romagna': ['Lambrusco'],
Umbria: ['Orvieto', 'Torgiano'],
Lazio: ['Frascati'],
Abruzzo: [],
Molise: ['Biferno', 'Pentro'],
Campania: ['Taurasi', 'Aglianico del Vulture', 'Fiano di Avellino'],
Puglia: ['Primitivo di Manduria', 'Salice Salentino'],
Basilicata: [],
Calabria: ['Cirò', 'Greco di Bianco'],
Sicily: ['Etna', 'Marsala', 'Pantelleria'],
Sardinia: ['Vermentino di Gallura', 'Cannonau di Sardegna']
},
FR: {
Alsace: [],
Ardeche: [],
Aquitaine: [],
Bordeaux: {
Médoc: ['Pauillac', 'Margaux', 'Saint-Estèphe', 'Saint-Julien'],
'Saint-Émilion': [],
Pomerol: [],
Graves: ['Pessac-Léognan'],
Sauternes: ['Barsac']
},
Burgundy: {
Chablis: [],
'Côte de Nuits': [
'Gevrey-Chambertin',
'Vosne-Romanée',
'Nuits-Saint-Georges'
],
'Côte de Beaune': ['Meursault', 'Puligny-Montrachet', 'Beaune'],
'Côte Chalonnaise': ['Mercurey'],
Mâconnais: ['Pouilly-Fuissé'],
Beaujolais: ['Morgon', 'Fleurie', 'Moulin a Vent']
},
Champagne: ['Montagne de Reims', 'Vallée de la Marne', 'Côte des Blancs'],
'Loire Valley': [
'Sancerre',
'Pouilly-Fumé',
'Vouvray',
'Muscadet',
'Anjou',
'Saumur',
'Chinon'
],
'Rhône Valley': [
'Côte-Rôtie',
'Hermitage',
'Condrieu',
'Saint-Joseph',
'Châteauneuf-du-Pape',
'Côtes du Rhône',
'Gigondas',
'Vacqueyras'
],
Provence: ['Côtes de Provence', 'Bandol'],
'Languedoc-Roussillon': [],
'Southwest France': ['Cahors', 'Madiran', 'Jurançon'],
Jura: ['Arbois', 'Côtes du Jura'],
Savoie: [],
Corsica: ['Patrimonio']
},
DE: {
Mosel: ['Saar', 'Ruwer'],
Pfalz: [],
Rheinhessen: [],
Nahe: [],
Rheingau: [],
'Hessische Bergstraße': [],
Franken: [],
Baden: [],
Württemberg: [],
'Saale-Unstrut': [],
Sachsen: [],
Ahr: []
},
CH: {
Vaud: ['Lavaux', 'Vevey', 'Morges', 'Nyon'],
Valais: ['Sierre', 'Sion', 'Martigny'],
Geneva: ['Lancy', 'Carouge'],
Ticino: ['Locarno', 'Bellinzona', 'Ascona'],
Neuchâtel: ['Val-de-Travers', 'Val-de-Ruz'],
Fribourg: ['Glâne', 'Sarine'],
Bern: ['Thun', 'Biel'],
Aargau: ['Baden', 'Brugg'],
Thurgau: ['Frauenfeld', 'Arbon'],
Zürich: ['Affoltern', 'Dietikon']
},
ES: {
Rioja: ['Rioja Alta', 'Rioja Alavesa', 'Rioja Oriental'],
Navarra: [],
'Rías Baixas': [],
'Ribeira Sacra': [],
Valdeorras: [],
Txakoli: ['Getariako Txakolina', 'Bizkaiko Txakolina', 'Arabako Txakolina'],
'Ribera del Duero': [],
Rueda: [],
Toro: [],
Cigales: [],
'La Mancha': [],
Valdepeñas: [],
Priorat: [],
Penedès: [],
Cava: [],
Montsant: [],
'Conca de Barberà': [],
'Jerez-Xérès': [],
'Montilla-Moriles': [],
Málaga: [],
'Sierras de Málaga': [],
'Condado de Huelva': [],
'Utiel-Requena': [],
Valencia: [],
Jumilla: [],
Yecla: [],
Bullas: [],
'Canary Islands': [],
'Balearic Islands': ['Binissalem', 'Pla i Llevant'],
Bierzo: [],
'Campo de Borja': [],
Aragón: ['Calatayud', 'Somontano'],
'Ribera del Guadiana': []
},
PT: {
Douro: ['Baixo Corgo', 'Cima Corgo', 'Douro Superior'],
Alentejo: ['Central', 'Litoral', 'Interior Norte', 'Interior Sul'],
Porto: ['Gaia'],
'Vinho Verde': [
'Amarante',
'Ave',
'Baião',
'Basto',
'Cávado',
'Lima',
'Lima Interior',
'Lima Litoral',
'Monção',
'Paiva',
'Sousa'
],
Tejo: [
'Alcobaça',
'Arruda dos Vinhos',
'Azeiteira',
'Benavente e Santarém',
'Bico do Cachorro',
'Borba',
'Casaínhos',
'Chamusca',
'Colares',
'Coruche',
'Fátima',
'Figueira da Foz',
'Golegã',
'Gualtar',
'Gândara',
'Mação',
'Mafra',
'Montemor-o-Novo',
'Montijo',
'Odivelas',
'Palmela',
'Pataias',
'Peniche',
'Porto de Mós',
'Reguengos de Monsaraz',
'Ribatejo',
'Sabugal',
'Setúbal',
'Tomar',
'Torres Vedras',
'Vila Franca de Xira'
],
Lisbon: [
'Alenquer',
'Arruda',
'Carcavelos e Cascais',
'Colares',
'Encoro',
'Estremoz',
'Oeiras',
'Palmela',
'Setúbal'
],
Madeira: [
'Machico',
'Santa Cruz',
'Caniço',
'Santo António da Serra',
'Ponta do Sol',
'Calheta',
'Funchal',
'Câmara de Lobos',
'Ribeira Brava',
'Paul do Mar',
'Canhas',
'Arco da Calheta',
'São Vicente',
'Porto Moniz',
'Paul da Serra'
]
},
AT: {
Wagram: ['Marchfeld'],
Kremstal: ['Krems', 'Spitz'],
Kamptal: ['Langenlois', 'Gumpoldskirchen'],
Wachau: [],
Thermenregion: ['Baden', 'Mödling'],
Wienerwald: [],
Südsteiermark: ['Grazer Bergland', 'Leibnitz', 'Deutschlandsberg'],
Burgenland: ['Neusiedlersee', 'Eisenberg', 'Rosalia'],
Styria: ['Südsteiermark', 'Weststeiermark'],
Salzburg: ['Flachgau'],
Tyrol: ['Innsbruck', 'Hall'],
Carinthia: ['Villach', 'Klagenfurt']
},
HU: {
Tokaj: [],
Eger: [],
Badacsony: [],
Balaton: [
'Balatonalmádi',
'Balatonboglár',
'Balatonfüred-Csopak',
'Balatonlelle',
'Balatonfüred',
'Somló'
],
Mátra: [],
Hegyalja: [],
Pannon: ['Szekszárd', 'Pécs', 'Baja', 'Sopron'],
Villány: []
},
CZ: {
'South Moravia': ['Znojmo', 'Břeclav', 'Hodonín', 'Kyjov', 'Vranov'],
'Zlín Region': ['Valašské Meziříčí', 'Kroměříž', 'Vsetín'],
'Olomouc Region': ['Prostějov', 'Olomouc', 'Litomyšl'],
'South Bohemia': ['České Budějovice', 'Písek', 'Strakonice'],
Plzeň: ['Rokycany', 'Domažlice']
},
MD: ['Iași', 'Botoșani', 'Vaslui', 'Neamț'],
RO: {
Transylvania: ['Cluj', 'Sibiu', 'Brașov', 'Mureș'],
Muntenia: ['Buzău', 'Dâmbovița', 'Prahova', 'Vrancea'],
Oltenia: ['Dolj', 'Gorj', 'Vâlcea', 'Mehedinți'],
Banat: ['Arad', 'Timiș', 'Caraș-Severin'],
Dobrogea: ['Constanța', 'Tulcea'],
Crisana: ['Bihor'],
Maramureș: ['Suceava', 'Maramureș']
},
GR: {
Attica: [],
'Central Greece': [
'Euboea',
'Boeotia',
'Phocis',
'Locris',
'Phthiotis',
'Phocis',
'Locris',
'Phthiotis'
],
Peloponnese: [
'Nemea',
'Mantineia',
'Achaia',
'Messenia',
'Laconia',
'Arcadia',
'Argolis',
'Corinthia'
],
Thrace: [],
Macedonia: [
'Thessaloniki',
'Kilkis',
'Pella',
'Florina',
'Kastoria',
'Imathia',
'Pella',
'Florina',
'Kastoria'
],
Thessaly: ['Trikala', 'Larissa', 'Magnesia', 'Karditsa', 'Trikala'],
'Ionian Islands': [
'Corfu',
'Zakynthos',
'Kefalonia',
'Lefkada',
'Ithaca',
'Paxos',
'Corfu',
'Zakynthos',
'Kefalonia',
'Lefkada',
'Ithaca',
'Paxos'
],
Cyclades: [
'Santorini',
'Paros',
'Naxos',
'Mykonos',
'Sifnos',
'Tinos',
'Santorini',
'Paros',
'Naxos',
'Mykonos',
'Sifnos',
'Tinos'
],
Crete: [],
'Aegean Islands': [
'Samos',
'Lesbos',
'Chios',
'Limnos',
'Ikaria',
'Samos',
'Lesbos',
'Chios',
'Limnos',
'Ikaria'
]
},
HR: {
Dalmatia: ['Hvar', 'Brac', 'Korcula', 'Peljesac', 'Dubrovnik-Riviera'],
Istria: [],
Slavonia: [],
Kvarner: ['Kvarner Bay', 'Labin'],
'Continental Croatia': ['Srijem', 'Moslavina', 'Podravina'],
'Central Croatia': ['Zagorje', 'Medgurje', 'Bilogora']
},
GE: {
Kakheti: ['Telavi', 'Sighnaghi', 'Akhasheni', 'Kvareli'],
Imereti: ['Kutaisi', 'Khoni', 'Tsqaltubo', 'Sachkhere'],
'Racha-Lechkhumi': [],
'Kvemo Svaneti': [],
Kartli: ['Tbilisi', 'Gori', 'Kaspi', 'Rustavi'],
Adjara: ['Batumi', 'Kobuleti'],
'Samegrelo-Zemo Svaneti': ['Poti', 'Zugdidi'],
Abkhazia: ['Gagra', 'Gudauta', 'Ochamchire'],
'South Georgia': ['Adigeni', 'Akhalkalaki', 'Bolnisi', 'Ninotsminda']
},
IL: {
Galilee: ['Golan Heights', 'Carmel Mountains'],
'Central District': ['Shomron', 'Shfela'],
Negev: ['Southern Negev', 'Central Negev'],
'Coastal Plain': ['Central Coastal Plain', 'Southern Coastal Plain']
},
LB: ['Bekaa Valley', 'Mount Lebanon', 'North Lebanon', 'South Lebanon'],
TR: {
Anatolia: [
'Konya',
'Aksaray',
'Nevşehir',
'Erzurum',
'Van',
'Bitlis',
'İzmir',
'Manisa',
'Ayvalik'
],
'Aegean Region': ['Urla', 'Foça', 'Bornova'],
Ayvalik: [],
'Marmara Region': ['İstanbul', 'Edirne'],
'Black Sea Region': ['Rize', 'Artvin', 'Trabzon'],
'Southeastern Anatolia': ['Gaziantep', 'Şanliurfa', 'Adana']
},
AM: ['Vayots Dzor', 'Ararat Valley', 'Gegharkunik', 'Tavush', 'Syunik'],
CA: {
Ontario: [
'Niagara Peninsula',
'Lake Erie North Shore',
'Pelee Island',
'Prince Edward County'
],
'British Columbia': [
'Okanagan Valley',
'Similkameen Valley',
'Fraser Valley',
'Southern Gulf Islands'
],
Quebec: ['Montreal', 'Eastern Townships', 'Charlevoix'],
'Nova Scotia': ['Annapolis Valley', 'Gaspereau Valley', 'North Shore'],
'New Brunswick': ['Annapolis Valley', 'Gaspereau Valley'],
'Prince Edward Island': ['Eastern PEI', 'Western PEI']
},
US: {
California: [
'Napa Valley',
'Sonoma Valley',
'Paso Robles',
'Santa Barbara County',
'Central Coast',
'Lodi',
'Mendocino County',
'Santa Cruz Mountains',
'Russian River Valley'
],
Oregon: [
'Willamette Valley',
'Rogue Valley',
'Umpqua Valley',
'Southern Oregon',
'Oregon Coast'
],
Washington: [
'Columbia Valley',
'Walla Walla Valley',
'Red Mountain',
'Snake River'
],
'New York': [
'Finger Lakes',
'Long Island',
'Hudson River',
'Western New York'
],
Virginia: [
'Monticello',
'Shenandoah Valley',
'Northern Virginia',
'Virginia Piedmont'
],
'Texas Hill Country': [],
'Arizona Sonoita': [],
'Colorado Grand Valley': [],
'Idaho Snake River Valley': [],
Michigan: ['Michigan Lake', 'Michigan Shore']
},
AR: {
Mendoza: ['Luján de Cuyo', 'Uco Valley', 'Maipú', 'San Rafael'],
Salta: ['Calchaquí Valleys', 'Quebrada de Humahuaca'],
'San Juan': ['Ullum', 'Calingasta', 'Tulum'],
'La Rioja': [],
Catamarca: ['Andalgalá', 'Belén'],
'San Luis': [
'Valle del Conlara',
'Valle del Potrerillos',
'Valle de las Carreras'
],
'Buenos Aires': [
'Partido de General Belgrano',
'Partido de Lobos',
'Partido de Tandil'
],
Neuquén: ['Valle de Río Negro', 'Valle de Agrelo']
},
CL: {
'Central Valley': [
'Maipo Valley',
'Casablanca Valley',
'San Antonio Valley',
'Maule Valley',
'Colchagua Valley'
],
'Coastal Regions': [
'Valle de Aconcagua',
'Valle de Limarí',
'Valle de Elqui'
],
'Southern Chile': ['Valle de Itata', 'Valle de Bío-Bío'],
'Northern Chile': ['Valle de Elqui']
},
ZA: {
'Western Cape': {
Stellenbosch: [
'Simonsberg-Stellenbosch',
'Jonkershoek-Stellenbosch',
'Stellenbosch Mountain'
],
Paarl: ['Franschhoek', 'Paarl Mountain'],
Constantia: ['Constantia Valley'],
Durbanville: ['Durbanville Hills'],
'Walker Bay': ['Hemel-en-Aarde'],
Swartland: ['Paardeberg']
},
'Eastern Cape': ['Jeffreys Bay'],
'Northern Cape': ['Augrabies']
},
AU: {
'South Eastern Australia': [],
'New South Wales': [
'Hunter Valley',
'Riverina',
'Mudgee',
'Orange',
'Cowra',
'Hilltops',
'Tumbarumba',
'Gundagai',
'Shoalhaven Coast',
'Southern Highlands',
'New England'
],
'South Australia': [
'Barossa Valley',
'McLaren Vale',
'Clare Valley',
'Coonawarra',
'Adelaide Hills',
'Eden Valley',
'Langhorne Creek',
'Padthaway',
'Kangaroo Island',
'Mount Gambier',
'Robe',
'Wrattonbully',
'Fleurieu Peninsula',
'Currency Creek',
'Southern Fleurieu'
],
Victoria: [
'Yarra Valley',
'Mornington Peninsula',
'Geelong',
'Bellarine Peninsula',
'Sunbury',
'Macedon Ranges',
'Heathcote',
'Bendigo',
'Pyrenees',
'Grampians',
'Great Western',
'Henty',
'Otway Ranges',
'King Valley',
'Alpine Valleys',
'Rutherglen',
'Glenrowan',
'Goulburn Valley',
'Strathbogie Ranges'
],
'Western Australia': [
'Margaret River',
'Great Southern',
'Swan Valley',
'Pemberton',
'Manjimup',
'Blackwood Valley',
'Geographe',
'Peel',
'Perth Hills',
'Frankland River',
'Mount Barker',
'Porongurup',
'Denmark',
'Albany',
'Esperance'
],
Tasmania: [
'Tamar Valley',
'Pipers River',
'East Coast',
'Coal Valley',
'Derwent Valley',
'Huon Valley'
],
Queensland: [
'Granite Belt',
'South Burnett',
'Darling Downs',
'Scenic Rim'
],
'Canberra District': [
'Canberra',
'Murrumbateman',
'Yass Valley',
'Gundaroo',
'Lake George'
]
},
NZ: {
'North Island': [
'Auckland',
'Gisborne',
`Hawke's Bay`,
'Wairarapa',
'Bay of Islands',
'Coromandel',
'Waikato'
],
'South Island': [
'Marlborough',
'Nelson',
'Waipara',
'Canterbury',
'Central Otago',
'Waipara Valley',
'North Canterbury',
'North Otago'
]
},
CN: {
Ningxia: {
Yinchuan: [],
'Helan Mountain': ['Qingtongxia', 'Wucaiwan', 'Qingshuihe', 'Jinfeng']
},
Shaanxi: ['Lishan', 'Fengxiang'],
Shandong: ['Yantai', 'Penglai', 'Weihai'],
Xinjiang: ['Turpan', 'Yili', 'Jinghe'],
Hebei: ['Changli', 'Qinhuangdao'],
Heilongjiang: ['Yichun', 'Qiqihar'],
Jilin: ['Baishan', 'Changchun'],
Liaoning: ['Dalian', 'Shenyang'],
'Inner Mongolia': ['Hohhot', 'Baotou'],
Guangxi: ['Nanning', 'Liuzhou'],
Hunan: ['Changsha', 'Yongzhou'],
Hubei: ['Wuhan'],
Sichuan: ['Chengdu', `Ya'an`],
Guizhou: ['Guiyang', 'Zunyi'],
Yunnan: ['Kunming', 'Dali']
},
JP: {
'Yamanashi Prefecture': ['Kofu Basin', 'Katsunuma', 'Fuefuki'],
'Hokkaido Prefecture': ['Biei', 'Tomakomai', 'Nanae'],
'Niigata Prefecture': ['Uonuma', 'Sannosawa'],
'Nagano Prefecture': ['Kiso Valley', 'Nagano'],
'Gifu Prefecture': ['Gujo', 'Mino'],
'Aichi Prefecture': ['Nishio', 'Seto'],
'Shizuoka Prefecture': ['Suruga', 'Shizuoka'],
'Kanagawa Prefecture': ['Yokohama', 'Kawasaki'],
'Chiba Prefecture': ['Noda', 'Chiba'],
'Ibaraki Prefecture': ['Tsukuba', 'Hitachi'],
'Tochigi Prefecture': ['Utsunomiya', 'Tochigi'],
'Gunma Prefecture': ['Maebashi', 'Gunma']
},
UK: {
Kent: ['Ashford', 'Canterbury', 'Dover', 'Maidstone', 'Sevenoaks'],
Sussex: ['Brighton', 'Eastbourne', 'Hastings', 'Chichester', 'Horsham'],
Hampshire: ['Winchester', 'Petersfield', 'Basingstoke', 'Andover'],
Wiltshire: ['Swindon', 'Chippenham', 'Salisbury', 'Trowbridge'],
Dorset: ['Poole', 'Bournemouth', 'Dorset'],
Devon: ['Exeter', 'Newton Abbot', 'Plymouth', 'Torquay'],
Somerset: ['Bath', 'Weston-super-Mare', 'Taunton', 'Yeovil'],
Gloucestershire: ['Cheltenham', 'Gloucester', 'Cirencester', 'Stroud'],
Worcestershire: ['Worcester', 'Malvern', 'Kidderminster', 'Bewdley'],
Warwickshire: [
'Warwick',
'Stratford-upon-Avon',
'Leamington Spa',
'Kenilworth'
],
Herefordshire: ['Hereford', 'Leominster', 'Ross-on-Wye', 'Kington'],
Shropshire: ['Shrewsbury', 'Telford', 'Ludlow', 'Oswestry']
},
UY: {
'Canelones Department': ['San Carlos', 'Melo', 'Paso de los Toros'],
'Montevideo Department': ['Carrasco', 'Punta del Este'],
'Colonia Department': ['Colonia del Sacramento', 'Villa Soriano'],
'San José Department': ['Progreso', 'Treinta y Tres'],
'Rocha Department': ['La Paloma', 'Rocha Sur'],
'Florida Department': ['Florida', 'Sarandí del Yí'],
'Soriano Department': ['Soriano', 'Río Negro'],
'Tacuarembó Department': ['Tacuarembó', 'Paso de los Toros'],
'Treinta y Tres Department': ['Treinta y Tres', 'Rivera']
}
}