Compare commits

...

99 Commits

Author SHA1 Message Date
dd64552243 Merge pull request 'feat(user): added PUT endpoint' () from user-put into staging
Reviewed-on: 
2025-04-29 09:27:42 +00:00
nostrdev-com
9e9e93d194 feat(user): added PUT endpoint 2025-04-29 12:22:11 +03:00
58a06d5c83 Merge pull request 'feat(review): added PUT/DELETE routes' () from reviews-put-delete into staging
Reviewed-on: 
2025-04-29 08:44:53 +00:00
nostrdev-com
08d02693ec feat(review): added PUT/DELETE routes 2025-04-29 11:42:19 +03:00
350065231c Merge pull request 'feat(spirits): added PUT and DELETE routes' () from spirits-put-delete into staging
Reviewed-on: 
2025-04-25 13:19:04 +00:00
nostrdev-com
1a400bf136 feat(spirits): added PUT and DELETE routes 2025-04-25 16:16:57 +03:00
e0ad07f5ee Merge pull request 'feat(sake): added PUT and DELETE routes' () from sake-put-delete into staging
Reviewed-on: 
2025-04-25 12:43:47 +00:00
nostrdev-com
5e49d1cef5 chore: fixed linting issue 2025-04-25 15:42:19 +03:00
nostrdev-com
fa6c925c94 chore: improved routes 2025-04-25 15:38:27 +03:00
nostrdev-com
61331e8e7c feat(sake): added PUT and DELETE routes 2025-04-25 14:47:17 +03:00
574011dcd5 Merge pull request 'fix(wine): fixed router' () from wine-put-delete into staging
Reviewed-on: 
2025-04-25 07:13:27 +00:00
nostrdev-com
e4a744b18a fix(wine): fixed router 2025-04-25 10:11:18 +03:00
434ede4bab Merge pull request 'feat(wine): added PUT and DELETE routes' () from wine-put-delete into staging
Reviewed-on: 
2025-04-25 06:51:23 +00:00
nostrdev-com
700d199e42 feat(wine): added PUT and DELETE routes 2025-04-25 09:48:17 +03:00
953db36d78 Merge pull request 'feat(coffee): added POST validation' () from coffee-validation into staging
Reviewed-on: 
2025-04-23 08:46:11 +00:00
nostrdev-com
a4832d2ce6 feat(coffee): added POST validation 2025-04-23 11:28:53 +03:00
049dec6aab Merge pull request 'feat: improved review validation' () from tasting-notes into staging
Reviewed-on: 
2025-04-22 14:19:00 +00:00
nostrdev-com
9375e91802 feat: improved review validation 2025-04-22 17:15:59 +03:00
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
nostrdev-com
13ffebbd77 chore: improved data models 2025-04-01 16:09:07 +03: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
nostrdev-com
404bf6cfc5 feat: added coffee model and routes 2025-03-31 17:01:21 +03: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
nostrdev-com
483ad3292d feat: added spirit model and routes 2025-03-31 16:31:59 +03: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
nostrdev-com
4173f00432 feat: added sake model and routes 2025-03-31 15:58:49 +03:00
eb0f389e07 Merge pull request 'schemas' () from schemas into staging
Reviewed-on: 
2025-03-31 12:25:49 +00:00
nostrdev-com
60f39158f5 feat: updated user model 2025-03-31 15:24:16 +03:00
nostrdev-com
750a7f21d1 chore: added comments to the data models 2025-03-31 15:22:59 +03:00
nostrdev-com
449f813ba2 feat: added wine model and corresponding types 2025-03-31 15:22:17 +03: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
nostrdev-com
210e89caa1 chore: changed review route to reviews 2025-03-27 18:04:08 +03: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
nostrdev-com
ce5cc193e7 feat: added nostr and review routes 2025-03-27 17:40:24 +03:00
14f0f71896 Merge pull request 'chore: put .env creation into release build step' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:56:04 +00:00
8661900adf Merge pull request 'chore(ci/cd): fix .env file' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:50:27 +00:00
3dd0368341 Merge pull request 'chore(ci/cd): debug .env issue' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:45:42 +00:00
1c762adbbb Merge pull request 'chore(ci/cd): fixed .env file creation' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:34:01 +00:00
13a8e51c9a Merge pull request 'chore(CI/CD): fixed .env file creation' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:13:24 +00:00
ea2e343854 Merge pull request 'chore(CI/CD): fixed env file' () from deps-fix into staging
Reviewed-on: 
2025-03-27 09:03:09 +00:00
f6689dd3b2 Merge pull request 'deps-fix' () from deps-fix into staging
Reviewed-on: 
2025-03-27 08:53:11 +00:00
dbf738c354 Merge pull request 'chore: fixed dependencies' () from deps-fix into staging
Reviewed-on: 
2025-03-27 08:33:15 +00:00
69 changed files with 7082 additions and 48 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

66
.vscode/settings.json vendored Normal file

@ -0,0 +1,66 @@
{
"cSpell.words": [
"Abuelo",
"amarela",
"Amaro",
"Amarula",
"Añejo",
"Arabica",
"Baijiu",
"biodynamic",
"Blanco",
"Cachaça",
"Caramelised",
"Caturra",
"colour",
"Colours",
"Coren",
"EAN",
"espadín",
"Genever",
"Jägermeister",
"Jaune",
"Joven",
"Kimoto",
"koji",
"Kona",
"Kouillou",
"Licor",
"Maragogype",
"Metaxa",
"Mirabelle",
"Nganda",
"Nostr",
"npub",
"ouro",
"Pacamara",
"Pacas",
"Pêche",
"Pisco",
"Platino",
"Poire",
"Pomme",
"Rakia",
"Reposado",
"Reserva",
"Robusta",
"RRP",
"schnorr",
"screwcap",
"SKU",
"Soju",
"Sokujō",
"Solera",
"sundried",
"tepeztate",
"Tequilana",
"tobalá",
"Typica",
"Umami",
"UPC",
"Verte",
"VSOP",
"Yamahai",
"Yuzu"
]
}

285
package-lock.json generated

@ -7,11 +7,18 @@
"": {
"name": "api",
"version": "1.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@noble/curves": "^1.9.0",
"crypto-js": "^4.2.0",
"currency-codes-ts": "^3.0.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mongodb": "^6.15.0"
"i18n-iso-countries": "^7.14.0",
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
@ -21,8 +28,9 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"@types/crypto-js": "^4.2.2",
"@types/express": "^5.0.1",
"@types/node": "^22.13.12",
"@types/node": "^22.14.1",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"globals": "^16.0.0",
@ -299,6 +307,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",
@ -402,6 +425,63 @@
"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.9.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.0.tgz",
"integrity": "sha512-7YDlXiNMdO1YZeH6t/kvopHHbIZzlxrCV9WLqCY6QhcXOoXiNCMDqJIglZ9Yjx5+w7Dz30TITFrlTjnRg7sKEg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves/node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=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/@noble/secp256k1": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-2.2.3.tgz",
"integrity": "sha512-l7r5oEQym9Us7EAigzg30/PQAvynhMt2uoYtT3t26eGDVm9Yii5mZ5jWSWmZ/oSIR2Et0xfc6DXrG0bZ787V3w==",
"license": "MIT",
"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",
@ -700,6 +780,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",
@ -1433,6 +1564,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",
@ -1534,6 +1686,13 @@
"@types/node": "*"
}
},
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -1623,13 +1782,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.13.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.12.tgz",
"integrity": "sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==",
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
@ -2942,6 +3101,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/crypto-random-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz",
@ -2971,6 +3136,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/currency-codes-ts": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/currency-codes-ts/-/currency-codes-ts-3.0.0.tgz",
"integrity": "sha512-ZJeCpq5uY2t8dDl4xdF15shkp5o8jrHcD4lHftK/O8j8xTHlXg0E5YhpZbRJvnLRaKe+JQh1/q1AI9Wc2Dl3Nw==",
"license": "MIT",
"dependencies": {
"lodash-es": "^4.17.21"
},
"engines": {
"node": ">=14"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@ -3071,6 +3248,12 @@
"wrappy": "1"
}
},
"node_modules/diacritics": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz",
"integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==",
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
@ -4553,6 +4736,18 @@
"node": ">=18.18.0"
}
},
"node_modules/i18n-iso-countries": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz",
"integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==",
"license": "MIT",
"dependencies": {
"diacritics": "1.3.0"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@ -4915,6 +5110,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",
@ -5482,7 +5690,6 @@
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.capitalize": {
@ -6231,6 +6438,62 @@
"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-tools/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/nostr-tools/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/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",
@ -11246,7 +11509,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",
@ -11301,9 +11564,9 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

@ -15,7 +15,9 @@
"lint": "eslint . --ext ts --report-unused-disable-directives --max-warnings 0",
"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"
"lint-staged": "lint-staged",
"start:db": "docker compose -f mongo-docker-compose.yml up -d",
"preinstall": "git config core.hooksPath .git-hooks"
},
"repository": {
"type": "git",
@ -25,9 +27,15 @@
"license": "ISC",
"description": "Cellar Social API",
"dependencies": {
"@noble/curves": "^1.9.0",
"crypto-js": "^4.2.0",
"currency-codes-ts": "^3.0.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mongodb": "^6.15.0"
"i18n-iso-countries": "^7.14.0",
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
@ -37,8 +45,9 @@
"@semantic-release/git": "^10.0.1",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.3",
"@types/crypto-js": "^4.2.2",
"@types/express": "^5.0.1",
"@types/node": "^22.13.12",
"@types/node": "^22.14.1",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"globals": "^16.0.0",

@ -1,7 +1,17 @@
import express, { Express } from 'express'
import dotenv from 'dotenv'
import { connectToDatabase } from './services/database.service'
import { usersRouter } from './routes/users.router'
import {
usersRouter,
nostrRouter,
reviewsRouter,
winesRouter,
sakeRouter,
spiritsRouter,
coffeeRouter
} from './routes'
import { Route } from './types'
import { authorizeRequest } from './middlewares'
dotenv.config()
@ -10,7 +20,15 @@ const port = process.env.PORT || 3000
connectToDatabase()
.then(() => {
app.use('/users', usersRouter)
app.use(authorizeRequest)
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}`)

@ -0,0 +1,71 @@
import { RequestHandler } from 'express'
import { NostrEvent } from 'nostr-tools'
import { decodeBase64 } from '../utils/coding'
import { verifyNostrSignature } from '../utils'
import { collections } from '../services/database.service'
import { DBcollection } from '../types'
import { User } from '../models'
export const authorizeRequest: RequestHandler = async (req, res, next) => {
const { authorization } = req.headers
if (!authorization) {
res.sendStatus(401)
} else {
const removeNostrPrefix = (str: string) => str.replace('Nostr ', '')
const decodedString = decodeBase64(removeNostrPrefix(authorization))
try {
const event: NostrEvent = JSON.parse(decodedString)
// verify Nostr signature
const verified = await verifyNostrSignature(event)
if (!verified) {
throw new Error('Nostr signature is not valid.')
}
const { pubkey } = event
const { locals } = res
const checkIfUserExists = async () => {
// collection of {url}:{method} strings that represent routes that do not require user existence check
const skipUserExistCheck = ['/users:POST']
const { url, method } = req
if (!skipUserExistCheck.includes(`${url}:${method}`)) {
const existingUser = await collections[
DBcollection.Users
]?.findOne<User>({
npub: pubkey
})
if (!existingUser) {
throw new Error('User does not exist.')
}
locals.userId = existingUser._id
locals.userRole = existingUser.role
}
}
await checkIfUserExists()
locals.npub = pubkey
next()
// TODO:
// 0. verify kind
// 1. verify tags
// 2. verify content
// 3. verify that event is not older than 5mins
} catch (error) {
console.error(`Error while processing Authorization header.`, error)
res.sendStatus(401)
}
}
}

1
src/middlewares/index.ts Normal file

@ -0,0 +1 @@
export * from './authorize'

26
src/models/coffee.ts Normal file

@ -0,0 +1,26 @@
import { ObjectId } from 'mongodb'
import { CoffeeProcessingType, CoffeeVariety, CoffeeRoast } from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
export class Coffee {
constructor(
public productCodeEAN: string, // Article Number (https://en.wikipedia.org/wiki/International_Article_Number)
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 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 origin: string, // origin
public name: string, // label
public producerId: ObjectId, // product producer
public variety: CoffeeVariety, // variety type and kind
public processingType: CoffeeProcessingType, // processing type
public roast: CoffeeRoast, // roast level
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product
public url?: string, // e.g. producer's website
public image?: string, // (optional image URL)cellar.social
public id?: ObjectId // database object id
) {}
}

7
src/models/index.ts Normal file

@ -0,0 +1,7 @@
export * from './user'
export * from './nostrEvent'
export * from './review'
export * from './wine'
export * from './sake'
export * from './spirit'
export * from './coffee'

12
src/models/nostrEvent.ts Normal file

@ -0,0 +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 _id?: ObjectId // database object id
) {}
}

17
src/models/review.ts Normal file

@ -0,0 +1,17 @@
import { ObjectId } from 'mongodb'
import { ProductType, RatingOption, TastingNote } from '../types'
export class Review {
constructor(
public nostrId: string, // unique identifier for the Nostr Event
public nostrEventId: ObjectId, // unique identifier for the NostrEvent DB instance
public productId: ObjectId, // unique identifier for the Product DB instance
public reviewerId: ObjectId, // unique identifier for the User DB instance
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 tastingNote: TastingNote, // an object representing tasting notes
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
) {}
}

44
src/models/sake.ts Normal file

@ -0,0 +1,44 @@
import { ObjectId } from 'mongodb'
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'
export class Sake {
constructor(
public productCodeEAN: string, // Article Number (https://en.wikipedia.org/wiki/International_Article_Number)
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 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 name: string, // label
public producerId: ObjectId, // product producer
public designation: SakeDesignation, // table, pure, blended
public polishRate: number, // %
public characteristic: SakeCharacteristic,
public starter: SakeStarter, // sake starter
public yeastStrain: SakeYeastStrain,
public volume: SakeVolume, // bottle volume
public alcohol: number, // alcohol percentage
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
public url?: string, // e.g. producer's website
public images?: string[], // (optional image URL)cellar.social
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
) {}
}

39
src/models/spirit.ts Normal file

@ -0,0 +1,39 @@
import { ObjectId } from 'mongodb'
import {
SpiritType,
SpiritVariant,
Ingredient,
VintageOption,
StandardDrink,
SpiritVolume,
SpiritCharacteristic
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
export class Spirit {
constructor(
public productCodeEAN: string, // Article Number (https://en.wikipedia.org/wiki/International_Article_Number)
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 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 name: string, // label
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 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
public url?: string, // e.g. producer's website
public images?: string[], // (optional image URL)cellar.social
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
) {}
}

@ -1,8 +1,11 @@
import { ObjectId } from 'mongodb'
import { UserRole } from '../types'
export default class User {
export class User {
constructor(
public name: string,
public id?: ObjectId
public name: string, // name, changeable
public npub: string, // npub
public role: UserRole, // user role (user, reviewer, producer)
public _id: ObjectId // database object id
) {}
}

56
src/models/wine.ts Normal file

@ -0,0 +1,56 @@
import { ObjectId } from 'mongodb'
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'
export class Wine {
constructor(
public productCodeEAN: string, // Article Number (https://en.wikipedia.org/wiki/International_Article_Number)
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: 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: WineRegion, // appellation, village, sub-region, vineyard
public name: string, // label
public producerId: ObjectId, // product producer
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 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, // 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
public description: string, // detailed description of the product
public url?: string, // e.g. producer's website
public images?: string[], // (optional image URL)cellar.social
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
) {}
}

@ -0,0 +1,71 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Coffee } from '../models'
import {
coffeeValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified
} from '../utils'
import Joi from 'joi'
import { DBcollection, DBinstance, HTTPmethod, ResponseStatus } from '../types'
export const coffeeRouter = express.Router()
coffeeRouter.use(express.json())
// GET
coffeeRouter.get('/', async (_req: Request, res: Response) => {
try {
const coffee = await collections.coffee?.find({}).toArray()
res.status(200).send(coffee)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
})
// POST
coffeeRouter.post('/', async (req: Request, res: Response) => {
try {
const {
error,
value: coffee
}: { error: Joi.ValidationError | undefined; value: Coffee } =
coffeeValidation(req.body)
if (error) {
throw error.details[0].message
}
const { productCodeEAN, productCodeUPC, productCodeSKU } = coffee
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
DBcollection.Coffee,
DBinstance.Coffee
)
const result = await collections[DBcollection.Coffee]?.insertOne(coffee)
if (result) {
handleReqSuccess(
res,
DBinstance.Coffee,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})

7
src/routes/index.ts Normal file

@ -0,0 +1,7 @@
export * from './users.router'
export * from './nostr.router'
export * from './reviews.router'
export * from './wines.router'
export * from './sake.router'
export * from './spirits.router'
export * from './coffee.router'

@ -0,0 +1,83 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { NostrEvent } from '../models'
import {
nostrEventValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified
} from '../utils'
import { Event } from 'nostr-tools'
import Joi from 'joi'
import { DBinstance, HTTPmethod, ResponseStatus } from '../types'
export const nostrRouter = express.Router()
nostrRouter.use(express.json())
// GET
nostrRouter.get('/', async (_req: Request, res: Response) => {
try {
const nostrEvents = await collections.nostrEvents?.find({}).toArray()
res.status(200).send(nostrEvents)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
})
// POST
nostrRouter.post('/', async (req: Request, res: Response) => {
try {
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) {
handleReqSuccess(
res,
DBinstance.NostrEvent,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})
// TODO:
// Add delete route

@ -0,0 +1,307 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Review, Sake, Spirit, User, Wine, NostrEvent } from '../models'
import {
reviewValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified,
handleGETreq,
handleReqUnauthorized,
handleReqNotFound,
compareObjects,
idValidation,
modificationPeriodExpired,
handleProductDELETEreq
} from '../utils'
import Joi from 'joi'
import {
DBcollection,
HTTPmethod,
ResponseStatus,
TastingNoteKey,
DBinstance,
UserRole,
ProductType
} from '../types'
import { ObjectId } from 'mongodb'
export const reviewsRouter = express.Router()
const dbInstance = DBinstance.Review
const dbCollection = DBcollection.Reviews
reviewsRouter.use(express.json())
// GET
reviewsRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
reviewsRouter.post('/', async (req: Request, res: Response) => {
try {
const {
error,
value: review
}: { error: Joi.ValidationError | undefined; value: Review } =
reviewValidation(req.body)
if (error) {
throw error.details[0].message
}
// user check
const { userId } = res.locals
const user = await collections[DBcollection.Users]?.findOne<User>({
_id: userId
})
if ((user && user.role !== UserRole.Reviewer) || !user?._id) {
handleReqUnauthorized(res)
} else {
review.reviewerId = user?._id
const { nostrId, productId, productType } = review
// nostr event check
const existingNostrEvent = await collections[
DBcollection.NostrEvents
]?.findOne<NostrEvent>({
nostrId
})
if (!existingNostrEvent || !existingNostrEvent._id) {
throw new Error('associated nostr event not found')
}
review.nostrEventId = existingNostrEvent._id
const existingReviewWithNostrEventId = await collections[
dbCollection
]?.findOne({
nostrEventId: existingNostrEvent._id
})
if (existingReviewWithNostrEventId) {
throw new Error('review with provided "nostrEventId" exists')
}
let product: Wine | Sake | Spirit | null | undefined = undefined
switch (productType) {
case ProductType.Wine:
product = await collections[DBcollection.Wines]?.findOne<Wine>({
_id: new ObjectId(productId)
})
break
case ProductType.Sake:
product = await collections[DBcollection.Sake]?.findOne<Sake>({
_id: new ObjectId(productId)
})
break
case ProductType.Spirit:
product = await collections[DBcollection.Spirits]?.findOne<Spirit>({
_id: new ObjectId(productId)
})
break
default:
break
}
if (!product || !product._id) {
throw new Error('product not found')
}
review.productId = product._id
// Add age property to tasting note, if product has vintage property
const { vintage } = product
if (typeof vintage === 'number') {
review.tastingNote[TastingNoteKey.TextureAndBalance].age =
new Date().getFullYear() - vintage
}
delete review.id
const result = await collections[dbCollection]?.insertOne(review)
if (result) {
handleReqSuccess(
res,
dbInstance,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqNotModified(res)
}
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})
// PUT
reviewsRouter.put('/', async (req: Request, res: Response) => {
try {
const {
error,
value: newReview
}: { error: Joi.ValidationError | undefined; value: Review } =
reviewValidation(req.body)
if (error) {
throw error.details[0].message
}
// user check
const { userId } = res.locals
const user = await collections[DBcollection.Users]?.findOne<User>({
_id: userId
})
if ((user && user.role !== UserRole.Reviewer) || !user?._id) {
handleReqUnauthorized(res)
} else {
newReview.reviewerId = user?._id
const { id, nostrId, productId, productType } = newReview
idValidation(id)
const existingReview = await collections[dbCollection]?.findOne<Review>({
_id: new ObjectId(id)
})
if (!existingReview) {
handleReqNotFound(res, dbInstance)
} else {
// check creation timestamp
const creationTimestamp = existingReview._id!.getTimestamp().getTime()
if (modificationPeriodExpired(creationTimestamp)) {
handleReqNotModified(res)
}
// productId check
if (existingReview?.productId.toString() !== `${newReview.productId}`) {
throw new Error('"productId" is not valid')
}
// productType check
if (existingReview?.productType !== newReview.productType) {
throw new Error('"productType" is not valid')
}
if (existingReview?.nostrId === nostrId) {
throw new Error('Nostr id has to be different')
}
// nostr event check
const existingNostrEvent = await collections[
DBcollection.NostrEvents
]?.findOne<NostrEvent>({
nostrId
})
if (!existingNostrEvent || !existingNostrEvent._id) {
throw new Error('associated nostr event not found')
}
let product: Wine | Sake | Spirit | null | undefined = undefined
switch (productType) {
case ProductType.Wine:
product = await collections[DBcollection.Wines]?.findOne<Wine>({
_id: new ObjectId(productId)
})
break
case ProductType.Sake:
product = await collections[DBcollection.Sake]?.findOne<Sake>({
_id: new ObjectId(productId)
})
break
case ProductType.Spirit:
product = await collections[DBcollection.Spirits]?.findOne<Spirit>({
_id: new ObjectId(productId)
})
break
default:
break
}
if (!product) {
throw new Error(`Associated "product" not found`)
}
// Add age property to tasting note, if product has vintage property
const { vintage } = product
if (typeof vintage === 'number') {
newReview.tastingNote[TastingNoteKey.TextureAndBalance].age =
new Date().getFullYear() - vintage
}
const existingReviewCopy = {
rating: existingReview.rating,
reviewText: existingReview.reviewText,
tastingNote: existingReview.tastingNote
}
const newReviewCopy = {
rating: newReview.rating,
reviewText: newReview.reviewText,
tastingNote: newReview.tastingNote
}
// compare existing and new review
if (compareObjects(existingReviewCopy, newReviewCopy)) {
handleReqNotModified(res)
}
// update review
const result = await collections[dbCollection]?.updateOne(
{
_id: existingReview._id
},
{ $set: newReviewCopy },
{ upsert: false }
)
if (result && result.modifiedCount === 1) {
handleReqSuccess(
res,
dbInstance,
existingReview._id!.toHexString(),
HTTPmethod.PUT
)
} else {
handleReqNotModified(res)
}
}
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})
// DELETE
reviewsRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(req, res, dbCollection, dbInstance)
})

36
src/routes/sake.router.ts Normal file

@ -0,0 +1,36 @@
import express, { Request, Response } from 'express'
import {
sakeValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
export const sakeRouter = express.Router()
const dbInstance = DBinstance.Sake
const dbCollection = DBcollection.Sake
sakeRouter.use(express.json())
// GET
sakeRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
sakeRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(req, res, dbCollection, dbInstance, sakeValidation)
})
// PUT
sakeRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(req, res, dbCollection, dbInstance, sakeValidation)
})
// DELETE
sakeRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(req, res, dbCollection, dbInstance)
})

@ -0,0 +1,48 @@
import express, { Request, Response } from 'express'
import {
spiritValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
export const spiritsRouter = express.Router()
const dbInstance = DBinstance.Spirit
const dbCollection = DBcollection.Spirits
spiritsRouter.use(express.json())
// GET
spiritsRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
spiritsRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(
req,
res,
dbCollection,
dbInstance,
spiritValidation
)
})
// PUT
spiritsRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(
req,
res,
dbCollection,
dbInstance,
spiritValidation
)
})
// DELETE
spiritsRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(req, res, dbCollection, dbInstance)
})

@ -1,41 +1,106 @@
// External Dependencies
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import User from '../models/user'
import { User } from '../models'
import {
userValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified,
handleGETreq
} from '../utils'
import Joi from 'joi'
import { DBcollection, DBinstance, HTTPmethod, ResponseStatus } from '../types'
// Global Config
export const usersRouter = express.Router()
const dbInstance = DBinstance.User
const dbCollection = DBcollection.Users
usersRouter.use(express.json())
// GET
usersRouter.get('/', async (_req: Request, res: Response) => {
try {
const users = await collections.users?.find({}).toArray()
res.status(200).send(users)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
res.status(500).send(error.message)
}
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
usersRouter.post('/', async (req: Request, res: Response) => {
try {
const newUser = req.body as User
const result = await collections.users?.insertOne(newUser)
const {
error,
value: newUser
}: { error: Joi.ValidationError | undefined; value: User } = userValidation(
req.body
)
if (error) {
throw error.details[0].message
}
const { npub } = res.locals
const existingUser = await collections[dbCollection]?.findOne({
npub
})
if (existingUser) {
throw new Error('user with provided "npub" exists')
}
newUser.npub = npub
const result = await collections[dbCollection]?.insertOne(newUser)
if (result) {
res
.status(201)
.send(`Successfully created a new user with id ${result.insertedId}`)
handleReqSuccess(
res,
DBinstance.User,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
res.status(500).send('Failed to create a new user.')
handleReqNotModified(res)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error(error)
res.status(400).send(error.message)
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})
// PUT
usersRouter.put('/', async (req: Request, res: Response) => {
try {
const { userId } = res.locals
const existingUser = await collections[dbCollection]?.findOne<User>({
_id: userId
})
const newName = req.body.name
if (existingUser && existingUser.name !== newName) {
// update user
const result = await collections[dbCollection]?.updateOne(
{
_id: existingUser._id
},
{ $set: { name: newName } },
{ upsert: false }
)
if (result && result.modifiedCount === 1) {
handleReqSuccess(
res,
dbInstance,
existingUser._id!.toHexString(),
HTTPmethod.PUT
)
} else {
handleReqNotModified(res)
}
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})

@ -0,0 +1,42 @@
import express, { Request, Response } from 'express'
import {
wineValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance, UserRole } from '../types'
export const winesRouter = express.Router()
const dbInstance = DBinstance.Wine
const dbCollection = DBcollection.Wines
winesRouter.use(express.json())
// GET
winesRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
winesRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(req, res, dbCollection, dbInstance, wineValidation)
})
// PUT
winesRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(req, res, dbCollection, dbInstance, wineValidation)
})
// DELETE
winesRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(
req,
res,
dbCollection,
dbInstance,
UserRole.Reviewer
)
})

@ -1,28 +1,62 @@
// External Dependencies
import * as mongoDB from 'mongodb'
import * as dotenv from 'dotenv'
import { DBcollection } from '../types'
// Global Variables
export const collections: { users?: mongoDB.Collection } = {}
export const collections: {
[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
export async function connectToDatabase() {
dotenv.config()
const client: mongoDB.MongoClient = new mongoDB.MongoClient(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
process.env.DB_CONN_STRING as any
)
const { DB_CONN_STRING } = process.env
if (typeof DB_CONN_STRING !== 'string') {
throw new Error(`DB_CONN_STRING environmental variable is missing.`)
}
const client: mongoDB.MongoClient = new mongoDB.MongoClient(DB_CONN_STRING)
await client.connect()
const db: mongoDB.Db = client.db(process.env.DB_NAME)
const usersCollection: mongoDB.Collection = db.collection('users')
const usersCollection: mongoDB.Collection = db.collection(DBcollection.Users)
const nostrEventsCollection: mongoDB.Collection = db.collection(
DBcollection.NostrEvents
)
const reviewsCollection: mongoDB.Collection = db.collection(
DBcollection.Reviews
)
const winesCollection: mongoDB.Collection = db.collection(DBcollection.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollection.Sake)
const spiritsCollection: mongoDB.Collection = db.collection(
DBcollection.Spirits
)
const coffeeCollection: mongoDB.Collection = db.collection(
DBcollection.Coffee
)
collections.users = usersCollection
collections.nostrEvents = nostrEventsCollection
collections.reviews = reviewsCollection
collections.wines = winesCollection
collections.sake = sakeCollection
collections.spirits = spiritsCollection
collections.coffee = coffeeCollection
console.log(
`Successfully connected to database: ${db.databaseName} and collection: ${usersCollection.collectionName}`
`Successfully connected to database: ${db.databaseName} and collections:
${Object.keys(collections)
.map((collection) => '- ' + collection)
.join('\n')}`
)
}

14
src/types/coding.ts Normal file

@ -0,0 +1,14 @@
export enum BufferEncoding {
ASCII = 'ascii',
UTF8 = 'utf8',
UTF_8 = 'utf-8',
UTF16LE = 'utf16le',
UTF_16LE = 'utf-16le',
UCS2 = 'ucs2',
UCS_2 = 'ucs-2',
BASE64 = 'base64',
BASE64URL = 'base64url',
LATIN1 = 'latin1',
BINARY = 'binary',
HEX = 'hex'
}

47
src/types/coffee.ts Normal file

@ -0,0 +1,47 @@
export enum CoffeeProcessingType {
DeCaff = 'De-caff',
Honey = 'Honey',
SemiDry = 'Semi-dry',
SwissWater = 'Swiss water',
Sundried = 'Sundried',
Washed = 'Washed'
}
export enum CoffeeType {
Arabica = 'Arabica',
Robusta = 'Robusta'
}
export enum ArabicaKind {
Typica = 'Typica',
Bourbon = 'Bourbon',
Caturra = 'Caturra',
Geisha = 'Geisha',
SL28 = 'SL28',
SL34 = 'SL34',
Maragogype = 'Maragogype',
Pacas = 'Pacas',
Pacamara = 'Pacamara',
Kona = 'Kona'
}
export enum RobustaKind {
Congolese = 'Congolese',
Nganda = 'Nganda',
Kouillou = 'Kouillou',
VietnameseRobusta = 'Vietnamese Robusta'
}
export interface CoffeeVariety {
[CoffeeType.Arabica]: ArabicaKind
[CoffeeType.Robusta]: RobustaKind
}
export enum CoffeeRoast {
Green = 'Green',
Light = 'Light',
Medium = 'Medium',
MediumDark = 'Medium-Dark',
Dark = 'Dark',
VeryDark = 'Very Dark'
}

19
src/types/database.ts Normal file

@ -0,0 +1,19 @@
export enum DBcollection {
Users = 'users',
NostrEvents = 'nostrEvents',
Reviews = 'reviews',
Wines = 'wines',
Sake = 'sake',
Spirits = 'spirits',
Coffee = 'coffee'
}
export enum DBinstance {
User = 'User',
NostrEvent = 'NostrEvent',
Review = 'Review',
Wine = 'Wine',
Sake = 'Sake',
Spirit = 'Spirit',
Coffee = 'Coffee'
}

11
src/types/index.ts Normal file

@ -0,0 +1,11 @@
export * from './database'
export * from './routes'
export * from './product'
export * from './user'
export * from './wine'
export * from './sake'
export * from './spirit'
export * from './coffee'
export * from './review'
export * from './review/'
export * from './coding'

13
src/types/locals.d.ts vendored Normal file

@ -0,0 +1,13 @@
import 'express'
import { ObjectId } from 'mongodb'
import { UserRole } from './user'
declare global {
namespace Express {
interface Locals {
userId: ObjectId
userRole: UserRole
npub: string
}
}
}

85
src/types/product.ts Normal file

@ -0,0 +1,85 @@
export type Availability = 'In stock' | 'Out of stock' | 'Discontinued'
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 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'
}
export enum ProductCode {
EAN = 'EAN',
UPC = 'UPC',
SKU = 'SKU'
}

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,
TropicalFruit,
MelonFruit,
Floral,
Vegetal,
Earth,
Microbiological,
Oxidation,
Umami,
Grain,
Dairy,
TextureAndBalanceKey,
Sweetness,
Concentration,
Body,
FlavourIntensity,
PalateLength,
ReasoningConcentration,
Quality,
ReadinessToDrink,
ReasoningKey,
TanninString,
TanninObject,
GrapeFruit,
DriedFruit,
Fault,
Sweet,
Botanical,
Nutty,
Salt,
Burnt,
SmokeEnum
} 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
| GrapeFruit
| BlackFruit
| TropicalFruit
| MelonFruit
| DriedFruit
[PrimaryFlavoursAndAromasKey.Floral]: Floral
[PrimaryFlavoursAndAromasKey.Sweet]: Sweet
[PrimaryFlavoursAndAromasKey.Vegetal]: Vegetal
[PrimaryFlavoursAndAromasKey.Grain]: Grain
[PrimaryFlavoursAndAromasKey.Botanical]: Botanical
[PrimaryFlavoursAndAromasKey.Nutty]: Nutty
[PrimaryFlavoursAndAromasKey.Earth]: Earth
[PrimaryFlavoursAndAromasKey.Dairy]: Dairy
[PrimaryFlavoursAndAromasKey.Umami]: Umami
[PrimaryFlavoursAndAromasKey.Microbiological]: Microbiological
[PrimaryFlavoursAndAromasKey.Salt]: Salt
[PrimaryFlavoursAndAromasKey.Burnt]: Burnt
[PrimaryFlavoursAndAromasKey.Smoke]: SmokeEnum
[PrimaryFlavoursAndAromasKey.Oxidation]: Oxidation
[PrimaryFlavoursAndAromasKey.Fault]: Fault
}
[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,43 @@
// 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'
}

63
src/types/review/fruit.ts Normal file

@ -0,0 +1,63 @@
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 GrapeFruit {
Grape = 'Grape'
}
export enum BlackFruit {
Blackberry = 'Blackberry',
Blackcurrant = 'Blackcurrant',
Boysenberry = 'Boysenberry',
Blueberry = 'Blueberry',
Olive = 'Olive'
}
export enum TropicalFruit {
Banana = 'Banana',
Lychee = 'Lychee',
Mango = 'Mango',
PassionFruit = 'Passion Fruit',
Pineapple = 'Pineapple',
Guava = 'Guava'
}
export enum MelonFruit {
Cantaloupe = 'Cantaloupe',
Honeydew = 'Honeydew'
}
export enum DriedFruit {
Raisin = 'Raisin',
Prune = 'Prune',
Fig = 'Fig',
Date = 'Date'
}

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

@ -0,0 +1,213 @@
export enum PrimaryFlavoursAndAromasKey {
Condition = 'condition',
Intensity = 'intensity',
Age = 'age',
Fruit = 'fruit',
Floral = 'floral',
Sweet = 'sweet',
Vegetal = 'vegetal',
Grain = 'grain',
Botanical = 'botanical',
Nutty = 'nutty',
Earth = 'earth',
Dairy = 'dairy',
Umami = 'umami',
Microbiological = 'microbiological',
Salt = 'salt',
Burnt = 'burnt',
Smoke = 'smoke',
Oxidation = 'oxidation',
Fault = 'fault'
}
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 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 Sweet {
Butterscotch = 'Butterscotch',
Caramel = 'Caramel',
Honey = 'Honey',
Molasses = 'Molasses',
Sugarcane = 'Sugarcane',
Toffee = 'Toffee'
}
export enum Vegetal {
Artichoke = 'Artichoke',
Asparagus = 'Asparagus',
Basil = 'Basil',
BellPepper = 'Bell Pepper',
BlackTea = 'Black Tea',
Capsicum = 'Capsicum',
Coriander = 'Coriander',
Eucalyptus = 'Eucalyptus',
Gooseberry = 'Gooseberry',
Grass = 'Grass',
Hay = 'Hay',
Herbaceous = 'Herbaceous',
Lemongrass = 'Lemongrass',
Mint = 'Mint',
Tomato = 'Tomato'
}
export enum Grain {
Barley = 'Barley',
Bran = 'Bran',
Cereal = 'Cereal',
CookedRice = 'Cooked Rice',
Corn = 'Corn',
Grains = 'Grains',
Malt = 'Malt',
Oats = 'Oats',
Porridge = 'Porridge',
RawRice = 'Raw Rice',
Rye = 'Rye',
SteamedRice = 'Steamed Rice',
Wheat = 'Wheat'
}
export enum Botanical {
Allspice = 'Allspice',
Anise = 'Anise',
Cedar = 'Cedar',
Cinnamon = 'Cinnamon',
Cloves = 'Cloves',
Coconut = 'Coconut',
Cumin = 'Cumin',
Dill = 'Dill',
Ginger = 'Ginger',
Juniper = 'Juniper',
Liquorice = 'Liquorice',
Nutmeg = 'Nutmeg',
Pepper = 'Pepper',
Sandalwood = 'Sandalwood',
Spices = 'Spices',
Tobacco = 'Tobacco'
}
export enum Nutty {
Almond = 'Almond',
Cashew = 'Cashew',
Chestnut = 'Chestnut',
Hazelnut = 'Hazelnut',
Marzipan = 'Marzipan',
Walnut = 'Walnut'
}
export enum Earth {
Gravel = 'Gravel',
Kerosene = 'Kerosene',
RedBeet = 'Red Beet',
Rocks = 'Rocks',
Slate = 'Slate',
Soil = 'Soil',
Terracotta = 'Terracotta'
}
export enum Dairy {
Butter = 'Butter',
Cheese = 'Cheese',
Cream = 'Cream',
Milk = 'Milk',
Yoghurt = 'Yoghurt'
}
export enum Umami {
FishSauce = 'Fish Sauce',
Seaweed = 'Seaweed',
Soy = 'Soy'
}
export enum Microbiological {
Animal = 'Animal',
BreadDough = 'Bread Dough',
Brioche = 'Brioche',
Farmyard = 'Farmyard',
Iodine = 'Iodine',
Leather = 'Leather',
Meaty = 'Meaty',
Mouse = 'Mouse',
Mushroom = 'Mushroom',
Truffles = 'Truffles',
Vinyl = 'Vinyl',
Yeasty = 'Yeasty'
}
export enum Salt {
Brine = 'Brine'
}
export enum Burnt {
Chocolate = 'Chocolate',
Coffee = 'Coffee',
ToastedBread = 'Toasted Bread',
RoastedNuts = 'Roasted Nuts',
CaramelisedNuts = 'Caramelised Nuts'
}
export enum SmokeEnum {
Ash = 'Ash',
Peat = 'Peat',
Smoke = 'Smoke'
}
export enum Oxidation {
Aldehydes = 'Aldehydes',
Madeirised = 'Madeirised',
Sherry = 'Sherry',
Staleness = 'Staleness'
}
export enum Fault {
BalsamicVinegar = 'Balsamic Vinegar',
Cabbage = 'Cabbage',
Eggs = 'Eggs',
Garlic = 'Garlic',
Mercaptans = 'Mercaptans',
Mustiness = 'Mustiness',
NailVarnishRemover = 'Nail Varnish Remover',
Onion = 'Onion',
Rubber = 'Rubber',
Solvent = 'Solvent',
SourMilk = 'Sour Milk',
Sweat = 'Sweat',
Trichloroanisole = 'Trichloroanisole',
WetCardboard = 'Wet Cardboard'
}

@ -0,0 +1,95 @@
import {
Floral,
Sweet,
Vegetal,
Botanical,
Nutty,
Earth,
Salt,
Burnt,
Fault,
CitrusFruit,
StoneFruit,
RedFruit,
GrapeFruit,
BlackFruit,
DriedFruit
} from '../'
export enum CoffeeCitrusFruit {
Grapefruit = CitrusFruit.Grapefruit,
Lemon = CitrusFruit.Lemon,
Orange = CitrusFruit.Orange
}
export enum CoffeeStoneFruit {
Apricot = StoneFruit.Apricot
}
export enum CoffeeRedFruit {
Raspberry = RedFruit.Raspberry
}
export enum CoffeeGrapeFruit {
Grape = GrapeFruit.Grape
}
export enum CoffeeBlackFruit {
Blueberry = BlackFruit.Blueberry
}
export enum CoffeeDriedFruit {
Raisin = DriedFruit.Raisin,
Date = DriedFruit.Date
}
export enum CoffeeFloral {
Jasmine = Floral.Jasmine,
Lavender = Floral.Lavender,
Rose = Floral.Rose,
Vanilla = Floral.Vanilla
}
export enum CoffeeSweet {
Caramel = Sweet.Caramel,
Honey = Sweet.Honey,
Molasses = Sweet.Molasses
}
export enum CoffeeVegetal {
Grass = Vegetal.Grass
}
export enum CoffeeBotanical {
Cinnamon = Botanical.Cinnamon,
Cloves = Botanical.Cloves,
Nutmeg = Botanical.Nutmeg,
Pepper = Botanical.Pepper,
Tobacco = Botanical.Tobacco
}
export enum CoffeeNutty {
Almond = Nutty.Almond,
Hazelnut = Nutty.Hazelnut,
Walnut = Nutty.Walnut
}
export enum CoffeeEarth {
Soil = Earth.Soil
}
export enum CoffeeSalt {
Brine = Salt.Brine
}
export enum CoffeeBurnt {
Chocolate = Burnt.Chocolate,
ToastedBread = Burnt.ToastedBread,
CaramelisedNuts = Burnt.CaramelisedNuts
}
export enum CoffeeFault {
BalsamicVinegar = Fault.BalsamicVinegar,
Mustiness = Fault.Mustiness,
WetCardboard = Fault.WetCardboard
}

@ -0,0 +1,4 @@
export * from './wine'
export * from './spirit'
export * from './sake'
export * from './coffee'

@ -0,0 +1,140 @@
import {
WhiteColour,
Floral,
Sweet,
Vegetal,
Grain,
Botanical,
Nutty,
Dairy,
Umami,
Microbiological,
Burnt,
SmokeEnum,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
TropicalFruit,
MelonFruit,
DriedFruit
} from '../'
export enum SakeColour {
WaterWhite = WhiteColour.WaterWhite,
LemonGreen = WhiteColour.LemonGreen,
Lemon = WhiteColour.Lemon,
Gold = WhiteColour.Gold,
WhiteBrown = WhiteColour.WhiteBrown
}
export enum SakeCitrusFruit {
Lemon = CitrusFruit.Lemon,
Orange = CitrusFruit.Orange,
Yuzu = CitrusFruit.Yuzu
}
export enum SakeAppleFruit {
Green = AppleFruit.Green,
Red = AppleFruit.Red,
Ripe = AppleFruit.Ripe
}
export enum SakeStoneFruit {
Apricot = StoneFruit.Apricot,
Nectarine = StoneFruit.Nectarine,
Peach = StoneFruit.Peach,
Plum = StoneFruit.Plum
}
export enum SakeRedFruit {
Cherry = RedFruit.Cherry,
Strawberry = RedFruit.Strawberry
}
export enum SakeTropicalFruit {
Banana = TropicalFruit.Banana,
Lychee = TropicalFruit.Lychee,
Mango = TropicalFruit.Mango,
PassionFruit = TropicalFruit.PassionFruit,
Pineapple = TropicalFruit.Pineapple,
Guava = TropicalFruit.Guava
}
export enum SakeMelonFruit {
Cantaloupe = MelonFruit.Cantaloupe,
Honeydew = MelonFruit.Honeydew
}
export enum SakeDriedFruit {
Date = DriedFruit.Date
}
export enum SakeFloral {
Elderflower = Floral.Elderflower,
Lilac = Floral.Lilac,
Potpourri = Floral.Potpourri
}
export enum SakeSweet {
Caramel = Sweet.Caramel,
Honey = Sweet.Honey,
Molasses = Sweet.Molasses,
Sugarcane = Sweet.Sugarcane
}
export enum SakeVegetal {
Basil = Vegetal.Basil,
Lemongrass = Vegetal.Lemongrass,
Mint = Vegetal.Mint
}
export enum SakeGrain {
Bran = Grain.Bran,
Malt = Grain.Malt,
Porridge = Grain.Porridge,
RawRice = Grain.RawRice,
SteamedRice = Grain.SteamedRice
}
export enum SakeBotanical {
Anise = Botanical.Anise,
Cinnamon = Botanical.Cinnamon,
Cloves = Botanical.Cloves,
Nutmeg = Botanical.Nutmeg,
Pepper = Botanical.Pepper
}
export enum SakeNutty {
Almond = Nutty.Almond,
Chestnut = Nutty.Chestnut,
Walnut = Nutty.Walnut
}
export enum SakeDairy {
Butter = Dairy.Butter,
Cheese = Dairy.Cheese,
Cream = Dairy.Cream,
Yoghurt = Dairy.Yoghurt
}
export enum SakeUmami {
Seaweed = Umami.Seaweed,
Soy = Umami.Soy
}
export enum SakeMicrobiological {
Meaty = Microbiological.Meaty
}
export enum SakeBurnt {
Chocolate = Burnt.Chocolate,
Coffee = Burnt.Coffee,
ToastedBread = Burnt.ToastedBread,
RoastedNuts = Burnt.RoastedNuts,
CaramelisedNuts = Burnt.CaramelisedNuts
}
export enum SakeSmoke {
Smoke = SmokeEnum.Smoke
}

@ -0,0 +1,206 @@
import {
WhiteColour,
AmberColour,
RoseColour,
RedColour,
BlueColour,
GreenColour,
Floral,
Vegetal,
Sweet,
Grain,
Botanical,
Dairy,
Umami,
Microbiological,
Burnt,
SmokeEnum,
Oxidation,
Fault,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
GrapeFruit,
BlackFruit,
TropicalFruit,
MelonFruit,
DriedFruit
} from '..'
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
}
export enum SpiritCitrusFruit {
Grapefruit = CitrusFruit.Grapefruit,
Lemon = CitrusFruit.Lemon,
Lime = CitrusFruit.Lime,
Marmalade = CitrusFruit.Marmalade,
Orange = CitrusFruit.Orange,
Yuzu = CitrusFruit.Yuzu
}
export enum SpiritAppleFruit {
Green = AppleFruit.Green,
Red = AppleFruit.Red,
Ripe = AppleFruit.Ripe
}
export enum SpiritStoneFruit {
Apricot = StoneFruit.Apricot,
Nectarine = StoneFruit.Nectarine,
Peach = StoneFruit.Peach,
Plum = StoneFruit.Plum
}
export enum SpiritRedFruit {
Cherry = RedFruit.Cherry,
Cranberry = RedFruit.Cranberry,
Pomegranate = RedFruit.Pomegranate,
Raspberry = RedFruit.Raspberry,
SourCherry = RedFruit.SourCherry,
Strawberry = RedFruit.Strawberry
}
export enum SpiritGrapeFruit {
Grape = GrapeFruit.Grape
}
export enum SpiritBlackFruit {
Blackberry = BlackFruit.Blackberry,
Blackcurrant = BlackFruit.Blackcurrant,
Boysenberry = BlackFruit.Boysenberry,
Blueberry = BlackFruit.Blueberry,
Olive = BlackFruit.Olive
}
export enum SpiritTropicalFruit {
Banana = TropicalFruit.Banana,
Lychee = TropicalFruit.Lychee,
Mango = TropicalFruit.Mango,
PassionFruit = TropicalFruit.PassionFruit,
Pineapple = TropicalFruit.Pineapple,
Guava = TropicalFruit.Guava
}
export enum SpiritMelonFruit {
Cantaloupe = MelonFruit.Cantaloupe,
Honeydew = MelonFruit.Honeydew
}
export enum SpiritDriedFruit {
Raisin = DriedFruit.Raisin,
Prune = DriedFruit.Prune,
Fig = DriedFruit.Fig,
Date = DriedFruit.Date
}
export enum SpiritFloral {
Rose = Floral.Rose,
Vanilla = Floral.Vanilla
}
export enum SpiritSweet {
Butterscotch = Sweet.Butterscotch,
Caramel = Sweet.Caramel,
Honey = Sweet.Honey,
Molasses = Sweet.Molasses,
Sugarcane = Sweet.Sugarcane,
Toffee = Sweet.Toffee
}
export enum SpiritVegetal {
Basil = Vegetal.Basil,
Coriander = Vegetal.Coriander,
Grass = Vegetal.Grass,
Herbaceous = Vegetal.Herbaceous,
Mint = Vegetal.Mint
}
export enum SpiritGrain {
Barley = Grain.Barley,
Bran = Grain.Bran,
Cereal = Grain.Cereal,
Corn = Grain.Corn,
Grains = Grain.Grains,
Oats = Grain.Oats,
Porridge = Grain.Porridge,
Rye = Grain.Rye,
SteamedRice = Grain.SteamedRice
}
export enum SpiritBotanical {
Allspice = Botanical.Allspice,
Anise = Botanical.Anise,
Cedar = Botanical.Cedar,
Cinnamon = Botanical.Cinnamon,
Coconut = Botanical.Coconut,
Cumin = Botanical.Cumin,
Dill = Botanical.Dill,
Ginger = Botanical.Ginger,
Juniper = Botanical.Juniper,
Liquorice = Botanical.Liquorice,
Nutmeg = Botanical.Nutmeg,
Pepper = Botanical.Pepper,
Spices = Botanical.Spices,
Tobacco = Botanical.Tobacco
}
export enum SpiritDairy {
Cheese = Dairy.Cheese,
Cream = Dairy.Cream
}
export enum SpiritUmami {
Seaweed = Umami.Seaweed
}
export enum SpiritMicrobiological {
Farmyard = Microbiological.Farmyard,
Leather = Microbiological.Leather,
Meaty = Microbiological.Meaty,
Mushroom = Microbiological.Mushroom,
Yeasty = Microbiological.Yeasty
}
export enum SpiritBurnt {
Chocolate = Burnt.Chocolate,
Coffee = Burnt.Coffee,
ToastedBread = Burnt.ToastedBread,
RoastedNuts = Burnt.RoastedNuts,
CaramelisedNuts = Burnt.CaramelisedNuts
}
export enum SpiritSmoke {
Smoke = SmokeEnum.Smoke
}
export enum SpiritOxidation {
Sherry = Oxidation.Sherry
}
export enum SpiritFault {
NailVarnishRemover = Fault.NailVarnishRemover,
Rubber = Fault.Rubber,
Solvent = Fault.Solvent
}

@ -0,0 +1,257 @@
import {
WhiteColour,
AmberColour,
RoseColour,
RedColour,
Floral,
Sweet,
Vegetal,
Botanical,
Nutty,
Earth,
Dairy,
Umami,
Microbiological,
Salt,
Burnt,
SmokeEnum,
Oxidation,
Fault,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
GrapeFruit,
BlackFruit,
TropicalFruit,
MelonFruit,
DriedFruit
} from '../'
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 WineCitrusFruit {
Grapefruit = CitrusFruit.Grapefruit,
Lemon = CitrusFruit.Lemon,
Lime = CitrusFruit.Lime,
Marmalade = CitrusFruit.Marmalade,
Orange = CitrusFruit.Orange,
Yuzu = CitrusFruit.Yuzu
}
export enum WineAppleFruit {
Green = AppleFruit.Green,
Red = AppleFruit.Red,
Ripe = AppleFruit.Ripe
}
export enum WineStoneFruit {
Apricot = StoneFruit.Apricot,
Nectarine = StoneFruit.Nectarine,
Peach = StoneFruit.Peach,
Plum = StoneFruit.Plum
}
export enum WineRedFruit {
Cherry = RedFruit.Cherry,
Cranberry = RedFruit.Cranberry,
Pomegranate = RedFruit.Pomegranate,
Raspberry = RedFruit.Raspberry,
SourCherry = RedFruit.SourCherry,
Strawberry = RedFruit.Strawberry
}
export enum WineGrapeFruit {
Grape = GrapeFruit.Grape
}
export enum WineBlackFruit {
Blackberry = BlackFruit.Blackberry,
Blackcurrant = BlackFruit.Blackcurrant,
Boysenberry = BlackFruit.Boysenberry,
Blueberry = BlackFruit.Blueberry,
Olive = BlackFruit.Olive
}
export enum WineTropicalFruit {
Banana = TropicalFruit.Banana,
Lychee = TropicalFruit.Lychee,
Mango = TropicalFruit.Mango,
PassionFruit = TropicalFruit.PassionFruit,
Pineapple = TropicalFruit.Pineapple,
Guava = TropicalFruit.Guava
}
export enum WineMelonFruit {
Cantaloupe = MelonFruit.Cantaloupe,
Honeydew = MelonFruit.Honeydew
}
export enum WineDriedFruit {
Raisin = DriedFruit.Raisin,
Prune = DriedFruit.Prune,
Fig = DriedFruit.Fig,
Date = DriedFruit.Date
}
export enum WineFloral {
Acacia = Floral.Acacia,
Elderflower = Floral.Elderflower,
Hibiscus = Floral.Hibiscus,
Honeysuckle = Floral.Honeysuckle,
Jasmine = Floral.Jasmine,
Lavender = Floral.Lavender,
Lilac = Floral.Lilac,
OrangeBlossom = Floral.OrangeBlossom,
Potpourri = Floral.Potpourri,
Rose = Floral.Rose,
Vanilla = Floral.Vanilla,
Violet = Floral.Violet
}
export enum WineSweet {
Butterscotch = Sweet.Butterscotch,
Caramel = Sweet.Caramel,
Honey = Sweet.Honey,
Molasses = Sweet.Molasses,
Toffee = Sweet.Toffee
}
export enum WineVegetal {
Artichoke = Vegetal.Artichoke,
Asparagus = Vegetal.Asparagus,
Basil = Vegetal.Basil,
BellPepper = Vegetal.BellPepper,
BlackTea = Vegetal.BlackTea,
Capsicum = Vegetal.Capsicum,
Coriander = Vegetal.Coriander,
Eucalyptus = Vegetal.Eucalyptus,
Gooseberry = Vegetal.Gooseberry,
Grass = Vegetal.Grass,
Hay = Vegetal.Hay,
Herbaceous = Vegetal.Herbaceous,
Lemongrass = Vegetal.Lemongrass,
Mint = Vegetal.Mint
}
export enum WineBotanical {
Allspice = Botanical.Allspice,
Anise = Botanical.Anise,
Cedar = Botanical.Cedar,
Cinnamon = Botanical.Cinnamon,
Cloves = Botanical.Cloves,
Coconut = Botanical.Coconut,
Cumin = Botanical.Cumin,
Dill = Botanical.Dill,
Ginger = Botanical.Ginger,
Juniper = Botanical.Juniper,
Liquorice = Botanical.Liquorice,
Nutmeg = Botanical.Nutmeg,
Pepper = Botanical.Pepper,
Sandalwood = Botanical.Sandalwood,
Spices = Botanical.Spices,
Tobacco = Botanical.Tobacco
}
export enum WineNutty {
Almond = Nutty.Almond,
Cashew = Nutty.Cashew,
Chestnut = Nutty.Chestnut,
Hazelnut = Nutty.Hazelnut,
Marzipan = Nutty.Marzipan,
Walnut = Nutty.Walnut
}
export enum WineEarth {
Gravel = Earth.Gravel,
Kerosene = Earth.Kerosene,
RedBeet = Earth.RedBeet,
Rocks = Earth.Rocks,
Slate = Earth.Slate,
Soil = Earth.Soil,
Terracotta = Earth.Terracotta
}
export enum WineDairy {
Butter = Dairy.Butter,
Cheese = Dairy.Cheese,
Cream = Dairy.Cream,
Yoghurt = Dairy.Yoghurt
}
export enum WineUmami {
Seaweed = Umami.Seaweed,
Soy = Umami.Soy
}
export enum WineMicrobiological {
Animal = Microbiological.Animal,
BreadDough = Microbiological.BreadDough,
Brioche = Microbiological.Brioche,
Farmyard = Microbiological.Farmyard,
Iodine = Microbiological.Iodine,
Leather = Microbiological.Leather,
Meaty = Microbiological.Meaty,
Mouse = Microbiological.Mouse,
Mushroom = Microbiological.Mushroom,
Truffles = Microbiological.Truffles,
Vinyl = Microbiological.Vinyl,
Yeasty = Microbiological.Yeasty
}
export enum WineSalt {
Brine = Salt.Brine
}
export enum WineBurnt {
Chocolate = Burnt.Chocolate,
Coffee = Burnt.Coffee,
ToastedBread = Burnt.ToastedBread,
RoastedNuts = Burnt.RoastedNuts,
CaramelisedNuts = Burnt.CaramelisedNuts
}
export enum WineSmoke {
Smoke = SmokeEnum.Smoke
}
export enum WineOxidation {
Aldehydes = Oxidation.Aldehydes,
Madeirised = Oxidation.Madeirised,
Sherry = Oxidation.Sherry,
Staleness = Oxidation.Staleness
}
export enum WineFault {
BalsamicVinegar = Fault.BalsamicVinegar,
Cabbage = Fault.Cabbage,
Eggs = Fault.Eggs,
Garlic = Fault.Garlic,
Mercaptans = Fault.Mercaptans,
Mustiness = Fault.Mustiness,
NailVarnishRemover = Fault.NailVarnishRemover,
Onion = Fault.Onion,
Rubber = Fault.Rubber,
Solvent = Fault.Solvent,
SourMilk = Fault.SourMilk,
Sweat = Fault.Sweat,
Trichloroanisole = Fault.Trichloroanisole,
WetCardboard = Fault.WetCardboard
}

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

26
src/types/routes.ts Normal file

@ -0,0 +1,26 @@
export enum Route {
Users = '/users',
NostrEvents = '/nostr',
Reviews = '/reviews',
Wines = '/wines',
Sake = '/sake',
Spirits = '/spirits',
Coffee = '/coffee'
}
export enum ResponseStatus {
OK = 200,
Created = 201,
NotModified = 304,
BadRequest = 400,
Unauthorized = 401,
NotFound = 404,
InternalServerError = 500
}
export enum HTTPmethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE'
}

130
src/types/sake.ts Normal file

@ -0,0 +1,130 @@
export enum SakeDesignation {
Table = 'Table',
Pure = 'Pure',
Blended = 'Blended' // Blended with Spirit - up to 10% of final volume
}
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'
}

93
src/types/spirit.ts Normal file

@ -0,0 +1,93 @@
export enum SpiritType {
White = 'White',
Dark = 'Dark',
Liqueurs = 'Liqueurs'
}
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 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'
}

4
src/types/user.ts Normal file

@ -0,0 +1,4 @@
export enum UserRole {
Reviewer = 'Reviewer', // the only user role that can submit a review
Producer = 'Producer' // the only user role that can submit a product
}

358
src/types/wine.ts Normal file

@ -0,0 +1,358 @@
export enum WineType {
White = 'White',
Amber = 'Amber',
Rose = 'Rose',
Red = 'Red'
}
export enum WhiteWineCharacteristic {
LightAromatic = 'Light Aromatic',
TexturalAndSavory = 'Textural and Savory',
RichAndFruitForward = 'Rich and Fruit Forward'
}
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')
}

7
src/utils/coding.ts Normal file

@ -0,0 +1,7 @@
import { BufferEncoding } from '../types'
export const encodeBase64 = (str: string): string =>
Buffer.from(str, BufferEncoding.UTF8).toString(BufferEncoding.BASE64)
export const decodeBase64 = (str: string): string =>
Buffer.from(str, BufferEncoding.BASE64).toString(BufferEncoding.UTF8)

1
src/utils/const.ts Normal file

@ -0,0 +1 @@
export const MODIFICATION_PERIOD = 24 * 60 * 60 * 1000 // 24h in milliseconds

2
src/utils/error.ts Normal file

@ -0,0 +1,2 @@
export const errorMessage = (err: unknown) =>
err instanceof Error ? err.message : err

9
src/utils/index.ts Normal file

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

73
src/utils/nostr.ts Normal file

@ -0,0 +1,73 @@
import { nip19 } from 'nostr-tools'
import sha256 from 'crypto-js/sha256'
import { Event } from 'nostr-tools'
import Hex from 'crypto-js/enc-hex'
import { schnorr } from '@noble/curves/secp256k1'
/**
* 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}$/)
}
const serializeEvent = (evt: Event) => {
return JSON.stringify([
0,
evt.pubkey,
evt.created_at,
evt.kind,
evt.tags,
evt.content
])
}
const getEventHash = (event: Event) => {
return sha256(serializeEvent(event)).toString(Hex)
}
export const verifyNostrSignature = async (event: Event) => {
const { sig, pubkey } = event
// FIXME: enable event expiry check
// const eventCreatedAt = event.created_at
// const timeNow = Math.round(Date.now() / 1000)
// const timeDifference = timeNow - eventCreatedAt
// const maxTimeDifference = 300 // 5 minutes in seconds
// if (timeDifference > maxTimeDifference) {
// throw new Error('Nostr signature verification failed. Event Expired.')
// }
const eventHash = getEventHash(event)
// verify nostr signature
return schnorr.verify(sig, eventHash, pubkey)
}

312
src/utils/route.ts Normal file

@ -0,0 +1,312 @@
import { Request, Response } from 'express'
import { errorMessage } from './error'
import {
DBcollection,
DBinstance,
HTTPmethod,
ProductCode,
ResponseStatus,
UserRole
} from '../types'
import { collections } from '../services/database.service'
import Joi from 'joi'
import { Sake, Spirit, Wine } from '../models'
import {
productCodeValidation,
alcoholToStandardDrinks,
volumeToMl,
idValidation,
copyObject,
compareObjects,
throwProductCodeError,
modificationPeriodExpired
} from './'
import { ObjectId } from 'mongodb'
export const handleGETreq = async (
res: Response,
dbCollection: DBcollection,
item: DBinstance
) => {
try {
const items = await collections[dbCollection]?.find({}).toArray()
handleReqSuccess(res, item, '', HTTPmethod.GET, items)
} catch (err) {
handleReqError(res, err, ResponseStatus.InternalServerError)
}
}
export const handleProductPOSTreq = async (
req: Request,
res: Response,
dbCollection: DBcollection,
dbInstance: DBinstance,
validation: (data: unknown) => Joi.ValidationResult
) => {
try {
const {
error,
value: item
}: { error: Joi.ValidationError | undefined; value: Wine | Sake | Spirit } =
validation(req.body)
if (error) {
throw error.details[0].message
}
const { userId, userRole } = res.locals
if (userRole !== UserRole.Producer) {
// only Producer can create new product
handleReqUnauthorized(res)
} else {
// product code validation
const { productCodeEAN, productCodeUPC, productCodeSKU } = item
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
dbCollection,
dbInstance
)
item.standardDrinks = alcoholToStandardDrinks(
item.alcohol,
volumeToMl(item.volume)
)
item.producerId = userId
const result = await collections[dbCollection]?.insertOne(item)
if (result) {
handleReqSuccess(
res,
dbInstance,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqError(res, `${dbInstance} was not stored`, 500)
}
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
}
export const handleProductPUTreq = async (
req: Request,
res: Response,
dbCollection: DBcollection,
dbInstance: DBinstance,
validation: (data: unknown) => Joi.ValidationResult
) => {
try {
const {
error,
value: item
}: { error: Joi.ValidationError | undefined; value: Wine | Sake | Spirit } =
validation(req.body)
if (error) {
throw error.details[0].message
}
// validate id
const id = item.id as string
idValidation(id)
const _id = new ObjectId(id)
const { userId } = res.locals
// check if product with id exists in the db
const existingItem = await collections[dbCollection]?.findOne({
_id,
producerId: userId
})
if (!existingItem) {
handleReqNotFound(res, dbInstance)
} else if (existingItem._id) {
const creationTimestamp = existingItem._id.getTimestamp().getTime()
item.standardDrinks = alcoholToStandardDrinks(
item.alcohol,
volumeToMl(item.volume)
)
const existingItemCopy = copyObject(existingItem)
delete existingItemCopy._id
delete existingItemCopy.producerId
const newItemCopy = copyObject(item)
delete newItemCopy.id
if (compareObjects(existingItemCopy, newItemCopy)) {
// no need to update
handleReqNotModified(res)
} else if (modificationPeriodExpired(creationTimestamp)) {
// expired
handleReqNotModified(res)
} else {
// validate product codes
const { productCodeEAN, productCodeUPC, productCodeSKU } = item
if (existingItem.productCodeEAN !== productCodeEAN) {
const existingItemWithEAN = await collections[dbCollection]?.findOne({
productCodeEAN
})
if (existingItemWithEAN) {
throwProductCodeError(dbInstance, ProductCode.EAN)
}
}
if (existingItem.productCodeUPC !== productCodeUPC) {
const existingItemWithUPC = await collections[dbCollection]?.findOne({
productCodeUPC
})
if (existingItemWithUPC) {
throwProductCodeError(dbInstance, ProductCode.UPC)
}
}
if (existingItem.productCodeSKU !== productCodeSKU) {
const existingItemWithSKU = await collections[dbCollection]?.findOne({
productCodeSKU
})
if (existingItemWithSKU) {
throwProductCodeError(dbInstance, ProductCode.SKU)
}
}
delete item.id
const result = await collections[dbCollection]?.findOneAndUpdate(
{
_id,
producerId: userId
},
{ $set: item }
)
if (!result) {
throw new Error(`"${dbInstance}" with provided "id" does not exist`)
}
handleReqSuccess(res, dbInstance, id, HTTPmethod.PUT)
}
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
}
export const handleProductDELETEreq = async (
req: Request,
res: Response,
dbCollection: DBcollection,
dbInstance: DBinstance,
userRole = UserRole.Reviewer
) => {
try {
const { id } = req.body
idValidation(id)
const _id = new ObjectId(id)
const { userId } = res.locals
// check if item with id exists in the db
const existingItem = await collections[dbCollection]?.findOne({
_id,
[`${userRole.toLocaleLowerCase()}Id`]: userId
})
if (!existingItem) {
handleReqNotFound(res, dbInstance)
} else if (existingItem._id) {
const result = await collections[dbCollection]?.deleteOne({ _id })
if (result && result.deletedCount === 1) {
handleReqSuccess(res, dbInstance, id, HTTPmethod.DELETE)
} else {
handleReqNotModified(res)
}
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.InternalServerError)
}
}
export const handleReqError = (
res: Response,
err: unknown,
resStatus: ResponseStatus
) => {
console.error(err)
res.status(resStatus).send(errorMessage(err))
}
export const handleReqSuccess = (
res: Response,
item: DBinstance,
itemId: string,
method: HTTPmethod.GET | HTTPmethod.POST | HTTPmethod.PUT | HTTPmethod.DELETE,
items?: unknown
) => {
switch (method) {
case HTTPmethod.GET:
res.status(ResponseStatus.OK).send(items)
break
case HTTPmethod.POST:
res
.status(ResponseStatus.Created)
.send(`Successfully created "${item}" with id "${itemId}"`)
break
case HTTPmethod.PUT:
res
.status(ResponseStatus.OK)
.send(`Successfully updated "${item}" with id "${itemId}"`)
break
case HTTPmethod.DELETE:
res
.status(ResponseStatus.OK)
.send(`Successfully deleted "${item}" with id "${itemId}"`)
break
default:
break
}
}
export const handleReqNotModified = (res: Response) =>
res.status(ResponseStatus.NotModified).send()
export const handleReqUnauthorized = (res: Response) =>
res.status(ResponseStatus.Unauthorized).send()
export const handleReqNotFound = (res: Response, item: DBinstance) =>
res
.status(ResponseStatus.NotFound)
.send(`"${item}" with provided "id" and associated with user not found`)

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

18
src/utils/utils.ts Normal file

@ -0,0 +1,18 @@
import { MODIFICATION_PERIOD } from './const'
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())
export const compareObjects = (a: object, b: object) =>
JSON.stringify(a) === JSON.stringify(b)
export const copyObject = (obj: object) => JSON.parse(JSON.stringify(obj))
export const modificationPeriodExpired = (creationTimestamp: number) =>
Date.now() - creationTimestamp > MODIFICATION_PERIOD

@ -0,0 +1,110 @@
import Joi from 'joi'
import {
CoffeeVariety,
CoffeeType,
ArabicaKind,
RobustaKind,
CoffeeProcessingType,
CoffeeRoast
} from '../../types'
import {
productCodeEANvalidation,
productCodeUPCvalidation,
productCodeSKUvalidation,
countryValidation,
nameValidation,
idJoiValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imagesValidation
} from './'
export const coffeeValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
region: Joi.string(),
origin: Joi.string(),
name: nameValidation,
producerId: idJoiValidation,
variety: Joi.object().custom((variety: CoffeeVariety, helper) => {
const message = (str: string) =>
helper.message({
custom: Joi.expression(str)
})
/**
* Variety key validation
*/
const validVarietyKeys = Object.values(CoffeeType)
const varietyKeys = Object.keys(variety)
if (varietyKeys.length !== 1) {
return message(
`provided "variety" is not valid. "variety" object has to contain only one key. Valid keys are: [${validVarietyKeys.join(', ')}]`
)
}
const varietyType = varietyKeys[0] as CoffeeType
if (!validVarietyKeys.includes(varietyType)) {
return message(
`provided "variety" is not valid. Valid keys for "variety" object are: [${validVarietyKeys.join(', ')}]`
)
}
/**
* Variety value validation
*/
const varietyKind = variety[varietyType]
const validArabicaKinds = Object.values(ArabicaKind)
const validRobustaKinds = Object.values(RobustaKind)
switch (varietyType) {
case CoffeeType.Arabica:
if (
typeof varietyKind !== 'string' ||
!validArabicaKinds.includes(varietyKind as ArabicaKind)
) {
return message(
`provided "variety" is not valid. Valid options for "${varietyType}" kind are: [${validArabicaKinds.join(', ')}]`
)
}
break
case CoffeeType.Robusta:
if (
typeof varietyKind !== 'string' ||
!validRobustaKinds.includes(varietyKind as RobustaKind)
) {
return message(
`provided "variety" is not valid. Valid options for "${varietyType}" kind are: [${validRobustaKinds.join(', ')}]`
)
}
break
default:
break
}
return variety
}),
processingType: Joi.string()
.valid(...Object.values(CoffeeProcessingType))
.required(),
roast: Joi.string()
.valid(...Object.values(CoffeeRoast))
.required(),
RRPamount: RRPamountValidation,
RRPcurrency: RRPcurrencyValidation,
description: descriptionValidation,
url: urlValidation,
images: imagesValidation
}).validate(data)

@ -0,0 +1,10 @@
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'
export * from './coffee'

@ -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,53 @@
import { collections } from '../../services/database.service'
import { DBcollection, DBinstance, ProductCode } from '../../types'
export const throwProductCodeError = (
instance: DBinstance,
code: ProductCode
) => {
throw new Error(`${instance} with provided "productCode${code}" exists`)
}
export const productCodeValidation = async (
ean: string,
upc: string,
sku: string,
collection: DBcollection,
dbInstance: DBinstance
) => {
if (!ean && !upc && !sku) {
throw new Error(
`provide "productCode${ProductCode.EAN}", "productCode${ProductCode.UPC}" or "productCode${ProductCode.SKU}"`
)
}
if (ean) {
const existingProduct = await collections[collection]?.findOne({
productCodeEAN: ean
})
if (existingProduct) {
throwProductCodeError(dbInstance, ProductCode.EAN)
}
}
if (upc) {
const existingProduct = await collections[collection]?.findOne({
productCodeUPC: upc
})
if (existingProduct) {
throwProductCodeError(dbInstance, ProductCode.UPC)
}
}
if (sku) {
const existingProduct = await collections[collection]?.findOne({
productCodeSKU: sku
})
if (existingProduct) {
throwProductCodeError(dbInstance, ProductCode.SKU)
}
}
}

@ -0,0 +1,942 @@
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,
TropicalFruit,
MelonFruit,
Floral,
Vegetal,
Earth,
Microbiological,
Oxidation,
Umami,
Grain,
Dairy,
TextureAndBalanceKey,
Sweetness,
Concentration,
TanninType,
RipeTannin,
UnripeTannin,
Body,
FlavourIntensity,
PalateLength,
ReasoningKey,
ReasoningConcentration,
Quality,
ReadinessToDrink,
RequiredPrimaryFlavoursAndAromasKey,
TanninString,
TanninObject,
GrapeFruit,
DriedFruit,
Sweet,
Botanical,
Nutty,
Salt,
Burnt,
SmokeEnum,
Fault,
WineFloral,
SakeFloral,
SpiritFloral,
CoffeeFloral,
WineSweet,
SakeSweet,
SpiritSweet,
CoffeeSweet,
WineVegetal,
SakeVegetal,
SpiritVegetal,
CoffeeVegetal,
SakeGrain,
SpiritGrain,
WineBotanical,
SakeBotanical,
SpiritBotanical,
CoffeeBotanical,
WineNutty,
SakeNutty,
CoffeeNutty,
WineEarth,
CoffeeEarth,
WineDairy,
SakeDairy,
SpiritDairy,
WineAppleFruit,
WineCitrusFruit,
WineStoneFruit,
WineRedFruit,
WineGrapeFruit,
WineBlackFruit,
WineTropicalFruit,
WineMelonFruit,
WineDriedFruit,
SakeCitrusFruit,
SakeAppleFruit,
SakeStoneFruit,
SakeRedFruit,
SakeTropicalFruit,
SakeMelonFruit,
SakeDriedFruit,
SpiritCitrusFruit,
SpiritAppleFruit,
SpiritStoneFruit,
SpiritRedFruit,
SpiritGrapeFruit,
SpiritBlackFruit,
SpiritTropicalFruit,
SpiritMelonFruit,
SpiritDriedFruit,
CoffeeCitrusFruit,
CoffeeStoneFruit,
CoffeeRedFruit,
CoffeeGrapeFruit,
CoffeeBlackFruit,
CoffeeDriedFruit,
WineUmami,
SakeUmami,
SpiritUmami,
WineMicrobiological,
SakeMicrobiological,
SpiritMicrobiological,
WineSalt,
CoffeeSalt,
WineBurnt,
SakeBurnt,
SpiritBurnt,
CoffeeBurnt,
WineSmoke,
SakeSmoke,
SpiritSmoke,
WineOxidation,
SpiritOxidation,
WineFault,
SpiritFault,
CoffeeFault
} from '../../types'
import { compareArrays, isObject } from '../utils'
import {
idJoiValidation,
nostrIdValidation,
productSpecificValidation,
validateStringValue
} from './'
export const reviewValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
id: idJoiValidation.optional(),
nostrId: nostrIdValidation,
productId: idJoiValidation,
reviewerId: idJoiValidation,
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 visualAssessment = tastingNote[TastingNoteKey.VisualAssessment]
const visualAssessmentKeys = Object.keys(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,
messageString?: string
) =>
message(
messageString ||
`provided "tastingNote" is not valid. Valid options for "${[noteKey, noteSubKey].join('-')}"${product ? ` for "${product}"` : ''} are: [${Object.values(options).join(', ')}]`
)
/**
* visualAssessment-clarity validation
*/
const clarity = visualAssessment[VisualAssessmentKey.Clarity]
if (validateStringValue(clarity, ClarityVisualAssessment)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Clarity,
ClarityVisualAssessment
)
}
/**
* visualAssessment-nature validation
*/
const nature = visualAssessment[VisualAssessmentKey.Nature]
if (validateStringValue(nature, NatureVisualAssessment)) {
return messageWithValidOptions(
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Nature,
NatureVisualAssessment
)
}
const productType: ProductType = helper.state.ancestors[0].productType
/**
* visualAssessment-colour validation
*/
// visualAssessment-colour product specific validation
const colourProductSpecificMessage = productSpecificValidation(
productType,
visualAssessment[VisualAssessmentKey.Colour],
{
...WhiteColour,
...AmberColour,
...RoseColour,
...RedColour,
...BlueColour,
...GreenColour
},
{
[ProductType.Wine]: WineColour,
[ProductType.Sake]: SakeColour,
[ProductType.Spirit]: SpiritColour,
[ProductType.Coffee]: {}
},
messageWithValidOptions,
TastingNoteKey.VisualAssessment,
VisualAssessmentKey.Colour
)
if (colourProductSpecificMessage) {
return colourProductSpecificMessage
}
/**
* primaryFlavoursAndAromas validation
*/
const primaryFlavoursAndAromas =
tastingNote[TastingNoteKey.PrimaryFlavoursAndAromas]
const primaryFlavoursAndAromasKeys = Object.keys(
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 =
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Condition]
if (validateStringValue(condition, Condition)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Condition,
Condition
)
}
// primaryFlavoursAndAromas-intensity validation
const intensity =
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Intensity]
if (validateStringValue(intensity, Intensity)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Intensity,
Intensity
)
}
// primaryFlavoursAndAromas-age validation
const age = primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Age]
if (validateStringValue(age, Age)) {
return messageWithValidOptions(
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Age,
Age
)
}
// primaryFlavoursAndAromas-fruit product specific validation
const fruitProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Fruit],
{
...CitrusFruit,
...AppleFruit,
...StoneFruit,
...RedFruit,
...GrapeFruit,
...BlackFruit,
...TropicalFruit,
...MelonFruit,
...DriedFruit
},
{
[ProductType.Wine]: {
...WineCitrusFruit,
...WineAppleFruit,
...WineStoneFruit,
...WineRedFruit,
...WineGrapeFruit,
...WineBlackFruit,
...WineTropicalFruit,
...WineMelonFruit,
...WineDriedFruit
},
[ProductType.Sake]: {
...SakeCitrusFruit,
...SakeAppleFruit,
...SakeStoneFruit,
...SakeRedFruit,
...SakeTropicalFruit,
...SakeMelonFruit,
...SakeDriedFruit
},
[ProductType.Spirit]: {
...SpiritCitrusFruit,
...SpiritAppleFruit,
...SpiritStoneFruit,
...SpiritRedFruit,
...SpiritGrapeFruit,
...SpiritBlackFruit,
...SpiritTropicalFruit,
...SpiritMelonFruit,
...SpiritDriedFruit
},
[ProductType.Coffee]: {
...CoffeeCitrusFruit,
...CoffeeStoneFruit,
...CoffeeRedFruit,
...CoffeeGrapeFruit,
...CoffeeBlackFruit,
...CoffeeDriedFruit
}
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Fruit
)
if (fruitProductSpecificMessage) {
return fruitProductSpecificMessage
}
// primaryFlavoursAndAromas-floral product specific validation
const floralProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Floral],
Floral,
{
[ProductType.Wine]: WineFloral,
[ProductType.Sake]: SakeFloral,
[ProductType.Spirit]: SpiritFloral,
[ProductType.Coffee]: CoffeeFloral
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Floral
)
if (floralProductSpecificMessage) {
return floralProductSpecificMessage
}
// primaryFlavoursAndAromas-sweet product specific validation
const sweetProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Sweet],
Sweet,
{
[ProductType.Wine]: WineSweet,
[ProductType.Sake]: SakeSweet,
[ProductType.Spirit]: SpiritSweet,
[ProductType.Coffee]: CoffeeSweet
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Sweet
)
if (sweetProductSpecificMessage) {
return sweetProductSpecificMessage
}
// primaryFlavoursAndAromas-vegetal product specific validation
const vegetalProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Vegetal],
Vegetal,
{
[ProductType.Wine]: WineVegetal,
[ProductType.Sake]: SakeVegetal,
[ProductType.Spirit]: SpiritVegetal,
[ProductType.Coffee]: CoffeeVegetal
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Vegetal
)
if (vegetalProductSpecificMessage) {
return vegetalProductSpecificMessage
}
// primaryFlavoursAndAromas-grain product specific validation
const grainProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Grain],
Grain,
{
[ProductType.Wine]: undefined,
[ProductType.Sake]: SakeGrain,
[ProductType.Spirit]: SpiritGrain,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Grain
)
if (grainProductSpecificMessage) {
return grainProductSpecificMessage
}
// primaryFlavoursAndAromas-botanical product specific validation
const botanicalProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Botanical],
Botanical,
{
[ProductType.Wine]: WineBotanical,
[ProductType.Sake]: SakeBotanical,
[ProductType.Spirit]: SpiritBotanical,
[ProductType.Coffee]: CoffeeBotanical
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Botanical
)
if (botanicalProductSpecificMessage) {
return botanicalProductSpecificMessage
}
// primaryFlavoursAndAromas-nutty product specific validation
const nuttyProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Nutty],
Nutty,
{
[ProductType.Wine]: WineNutty,
[ProductType.Sake]: SakeNutty,
[ProductType.Spirit]: undefined,
[ProductType.Coffee]: CoffeeNutty
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Nutty
)
if (nuttyProductSpecificMessage) {
return nuttyProductSpecificMessage
}
// primaryFlavoursAndAromas-earth product specific validation
const earthProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Earth],
Earth,
{
[ProductType.Wine]: WineEarth,
[ProductType.Sake]: undefined,
[ProductType.Spirit]: undefined,
[ProductType.Coffee]: CoffeeEarth
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Earth
)
if (earthProductSpecificMessage) {
return earthProductSpecificMessage
}
// primaryFlavoursAndAromas-dairy product specific validation
const dairyProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Dairy],
Dairy,
{
[ProductType.Wine]: WineDairy,
[ProductType.Sake]: SakeDairy,
[ProductType.Spirit]: SpiritDairy,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Dairy
)
if (dairyProductSpecificMessage) {
return dairyProductSpecificMessage
}
// primaryFlavoursAndAromas-umami product specific validation
const umamiProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Umami],
Umami,
{
[ProductType.Wine]: WineUmami,
[ProductType.Sake]: SakeUmami,
[ProductType.Spirit]: SpiritUmami,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Umami
)
if (umamiProductSpecificMessage) {
return umamiProductSpecificMessage
}
// primaryFlavoursAndAromas-microbiological product specific validation
const microbiologicalProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Microbiological],
Microbiological,
{
[ProductType.Wine]: WineMicrobiological,
[ProductType.Sake]: SakeMicrobiological,
[ProductType.Spirit]: SpiritMicrobiological,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Microbiological
)
if (microbiologicalProductSpecificMessage) {
return microbiologicalProductSpecificMessage
}
// primaryFlavoursAndAromas-salt product specific validation
const saltProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Salt],
Salt,
{
[ProductType.Wine]: WineSalt,
[ProductType.Sake]: undefined,
[ProductType.Spirit]: undefined,
[ProductType.Coffee]: CoffeeSalt
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Salt
)
if (saltProductSpecificMessage) {
return saltProductSpecificMessage
}
// primaryFlavoursAndAromas-burnt product specific validation
const burntProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Burnt],
Burnt,
{
[ProductType.Wine]: WineBurnt,
[ProductType.Sake]: SakeBurnt,
[ProductType.Spirit]: SpiritBurnt,
[ProductType.Coffee]: CoffeeBurnt
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Burnt
)
if (burntProductSpecificMessage) {
return burntProductSpecificMessage
}
// primaryFlavoursAndAromas-smoke product specific validation
const smokeProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Smoke],
SmokeEnum,
{
[ProductType.Wine]: WineSmoke,
[ProductType.Sake]: SakeSmoke,
[ProductType.Spirit]: SpiritSmoke,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Smoke
)
if (smokeProductSpecificMessage) {
return smokeProductSpecificMessage
}
// primaryFlavoursAndAromas-oxidation product specific validation
const oxidationProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Oxidation],
Oxidation,
{
[ProductType.Wine]: WineOxidation,
[ProductType.Sake]: undefined,
[ProductType.Spirit]: SpiritOxidation,
[ProductType.Coffee]: undefined
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Oxidation
)
if (oxidationProductSpecificMessage) {
return oxidationProductSpecificMessage
}
// primaryFlavoursAndAromas-fault product specific validation
const faultProductSpecificMessage = productSpecificValidation(
productType,
primaryFlavoursAndAromas[PrimaryFlavoursAndAromasKey.Fault],
Fault,
{
[ProductType.Wine]: WineFault,
[ProductType.Sake]: undefined,
[ProductType.Spirit]: SpiritFault,
[ProductType.Coffee]: CoffeeFault
},
messageWithValidOptions,
TastingNoteKey.PrimaryFlavoursAndAromas,
PrimaryFlavoursAndAromasKey.Fault
)
if (faultProductSpecificMessage) {
return faultProductSpecificMessage
}
/**
* textureAndBalance validation
*/
const textureAndBalance = tastingNote[TastingNoteKey.TextureAndBalance]
const textureAndBalanceKeys = Object.keys(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 = textureAndBalance[TextureAndBalanceKey.Sweetness]
if (validateStringValue(sweetness, Sweetness)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Sweetness,
Sweetness
)
}
// textureAndBalance-acidity validation
const acidity = textureAndBalance[TextureAndBalanceKey.Acidity]
if (validateStringValue(acidity, Concentration)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Acidity,
Concentration
)
}
// textureAndBalance-tannin validation
const tannin = 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 = (
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 = (
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 = textureAndBalance[TextureAndBalanceKey.Alcohol]
if (validateStringValue(alcohol, Concentration)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Alcohol,
Concentration
)
}
// textureAndBalance-body validation
const body = textureAndBalance[TextureAndBalanceKey.Body]
if (validateStringValue(body, Body)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Body,
Body
)
}
// textureAndBalance-flavourIntensity validation
const flavourIntensity =
textureAndBalance[TextureAndBalanceKey.FlavourIntensity]
if (validateStringValue(flavourIntensity, FlavourIntensity)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.FlavourIntensity,
FlavourIntensity
)
}
// textureAndBalance-palateLength validation
const palateLength =
textureAndBalance[TextureAndBalanceKey.PalateLength]
if (validateStringValue(palateLength, PalateLength)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.PalateLength,
PalateLength
)
}
// textureAndBalance-reasoning validation
const reasoning = 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 =
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 =
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 =
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 = textureAndBalance[TextureAndBalanceKey.Quality]
if (validateStringValue(quality, Quality)) {
return messageWithValidOptions(
TastingNoteKey.TextureAndBalance,
TextureAndBalanceKey.Quality,
Quality
)
}
// textureAndBalance-readinessToDrink validation
const readinessToDrink =
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,
idJoiValidation,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imagesValidation
} from './'
import { sakePolishMap } from '../'
export const sakeValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
id: idJoiValidation.optional(),
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
region: Joi.string(),
name: nameValidation,
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,
images: imagesValidation
}).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,
idJoiValidation,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
imagesValidation
} from './'
export const spiritValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
id: idJoiValidation.optional(),
productCodeEAN: productCodeEANvalidation,
productCodeUPC: productCodeUPCvalidation,
productCodeSKU: productCodeSKUvalidation,
country: countryValidation,
region: Joi.string(),
name: nameValidation,
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,
images: imagesValidation
}).validate(data)

@ -0,0 +1,10 @@
import Joi from 'joi'
import { UserRole } from '../../types'
export const userValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
name: Joi.string().required(),
role: Joi.string()
.valid(...Object.values(UserRole))
.required()
}).validate(data)

@ -0,0 +1,190 @@
import Joi from 'joi'
import {
WhiteColour,
AmberColour,
RoseColour,
RedColour,
BlueColour,
GreenColour,
CitrusFruit,
AppleFruit,
StoneFruit,
RedFruit,
GrapeFruit,
BlackFruit,
TropicalFruit,
MelonFruit,
DriedFruit,
Floral,
ProductType,
VintageOption,
TastingNoteKey,
VisualAssessmentKey,
PrimaryFlavoursAndAromasKey,
TextureAndBalanceKey,
Sweet,
Vegetal,
Grain,
Botanical,
Nutty,
Earth,
Dairy,
Umami,
Microbiological,
Salt,
Burnt,
SmokeEnum,
Oxidation,
Fault
} from '../../types'
import { npubToHex, validateHex } from '../nostr'
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 idJoiValidation = Joi.string().length(24).required()
export const nostrIdValidation = Joi.string().min(64).max(64).required()
export const idValidation = (id?: string, key = 'id') => {
if (!id) {
throw new Error(`"${key}" is required`)
} else if (id.length !== 24) {
throw new Error(`"${key}" is not valid`)
}
}
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()
// TODO: improve url validation
export const imagesValidation = Joi.array().items(Joi.string())
export const validateStringValue = (
value: unknown,
validOptions: { [key: string]: string }
) => typeof value !== 'string' || !Object.values(validOptions).includes(value)
export const productSpecificValidation = (
productType: ProductType,
value:
| WhiteColour
| AmberColour
| RoseColour
| RedColour
| BlueColour
| GreenColour
| CitrusFruit
| AppleFruit
| StoneFruit
| RedFruit
| GrapeFruit
| BlackFruit
| TropicalFruit
| MelonFruit
| DriedFruit
| Floral
| Sweet
| Vegetal
| Grain
| Botanical
| Nutty
| Earth
| Dairy
| Umami
| Microbiological
| Salt
| Burnt
| SmokeEnum
| Oxidation
| Fault,
propertyValidOptions: { [key: string]: string },
productSpecificValidOptions: {
[key in ProductType]: { [key: string]: string } | undefined
},
messageCallback: (
noteKey: TastingNoteKey,
noteSubKey:
| VisualAssessmentKey
| PrimaryFlavoursAndAromasKey
| TextureAndBalanceKey,
options: object,
product?: string,
messageString?: string
) => Joi.ErrorReport,
noteKey: TastingNoteKey,
noteSubKey:
| VisualAssessmentKey
| PrimaryFlavoursAndAromasKey
| TextureAndBalanceKey
) => {
// FIXME: fix coffee-color validation
if (
noteSubKey === VisualAssessmentKey.Colour &&
productType === ProductType.Coffee
) {
return
}
if (value && validateStringValue(value, propertyValidOptions)) {
return messageCallback(noteKey, noteSubKey, propertyValidOptions)
}
const productValidOptions = productSpecificValidOptions[productType]
if (value && !productValidOptions) {
return messageCallback(
noteKey,
noteSubKey,
{},
productType,
`provided "tastingNote" is not valid. "${[noteKey, noteSubKey].join('-')}" is not supported for "${productType}"`
)
}
if (
value &&
productValidOptions &&
validateStringValue(value, productValidOptions)
) {
return messageCallback(
noteKey,
noteSubKey,
productValidOptions,
productType
)
}
}
export const npubValidation = (npub: string) => {
const hex = npubToHex(npub)
if (!hex || !validateHex(hex)) {
throw new Error('"npub" is not valid')
}
}

@ -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,
volumeValidation,
alcoholValidation,
RRPamountValidation,
RRPcurrencyValidation,
descriptionValidation,
urlValidation,
idJoiValidation,
imagesValidation
} from './'
export const wineValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
id: idJoiValidation.optional(),
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,
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,
images: imagesValidation
}).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']
}
}