Compare commits

..

82 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
638dc9cd42 Merge pull request 'feat: added coffee model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 14:02:33 +00:00
05d0709f49 Merge pull request 'feat: added spirit model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 13:32:57 +00:00
19189c2866 Merge pull request 'feat: added sake model and routes' () from schemas into staging
Reviewed-on: 
2025-03-31 13:00:06 +00:00
eb0f389e07 Merge pull request 'schemas' () from schemas into staging
Reviewed-on: 
2025-03-31 12:25:49 +00:00
bf0e15164a Merge pull request 'chore: changed review route to reviews' () from schemas into staging
Reviewed-on: 
2025-03-27 15:05:00 +00:00
35c2debfae Merge pull request 'feat: added nostr and review routes' () from schemas into staging
Reviewed-on: 
2025-03-27 14:44:11 +00:00
67 changed files with 6665 additions and 531 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

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

252
package-lock.json generated

@ -7,13 +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",
"i18n-iso-countries": "^7.14.0",
"mongodb": "^6.15.0"
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
@ -23,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",
@ -301,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",
@ -404,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",
@ -702,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",
@ -1435,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",
@ -1536,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",
@ -1625,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": {
@ -2944,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",
@ -4947,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",
@ -6262,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",
@ -11277,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",
@ -11332,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"
},

@ -16,7 +16,8 @@
"lint:fix": "eslint . --fix --ext ts --report-unused-disable-directives --max-warnings 0",
"lint:staged": "eslint --fix --ext ts --report-unused-disable-directives --max-warnings 0",
"lint-staged": "lint-staged",
"start:db": "docker compose -f mongo-docker-compose.yml up -d"
"start:db": "docker compose -f mongo-docker-compose.yml up -d",
"preinstall": "git config core.hooksPath .git-hooks"
},
"repository": {
"type": "git",
@ -26,11 +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",
"i18n-iso-countries": "^7.14.0",
"mongodb": "^6.15.0"
"joi": "^17.13.3",
"mongodb": "^6.15.0",
"nostr-tools": "^2.11.1"
},
"devDependencies": {
"@eslint/js": "^9.23.0",
@ -40,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",

@ -10,7 +10,8 @@ import {
spiritsRouter,
coffeeRouter
} from './routes'
import { Routes } from './types'
import { Route } from './types'
import { authorizeRequest } from './middlewares'
dotenv.config()
@ -19,13 +20,15 @@ const port = process.env.PORT || 3000
connectToDatabase()
.then(() => {
app.use(Routes.Users, usersRouter)
app.use(Routes.NostrEvents, nostrRouter)
app.use(Routes.Reviews, reviewsRouter)
app.use(Routes.Wines, winesRouter)
app.use(Routes.Sake, sakeRouter)
app.use(Routes.Spirits, spiritsRouter)
app.use(Routes.Coffee, coffeeRouter)
app.use(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'

@ -11,10 +11,10 @@ export class Coffee {
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 variety: CoffeeVariety, // variety type and kind
public processingType: CoffeeProcessingType, // processing type
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

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

@ -1,12 +1,17 @@
import { ObjectId } from 'mongodb'
import { ProductType, RatingOption, TastingNote } from '../types'
export class Review {
constructor(
public eventId: string, // foreign key referencing the nostrEvents collection
public productId: string, // unique identifier for the product
public rating: number, // numerical rating, e.g., 1-100
public 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 tastingNotes: string[], // array of tasting notes, e.g., flavours, aromas
public id?: ObjectId // database object id
public tastingNote: TastingNote, // an object representing tasting notes
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
) {}
}

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

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

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

@ -1,5 +1,19 @@
import { ObjectId } from 'mongodb'
import { WineType, Viticulture, BottleClosure, Vintage } from '../types'
import {
WineType,
Viticulture,
BottleClosure,
VintageOption,
StandardDrink,
WineRegion,
WineVolume,
WineStyle,
WhiteWineCharacteristic,
AmberWineCharacteristic,
RoseWineCharacteristic,
RedWineCharacteristic,
GrapeVarietal
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -9,27 +23,34 @@ export class Wine {
public productCodeUPC: string, // Product Code (https://en.wikipedia.org/wiki/Universal_Product_Code)
public productCodeSKU: string, // Stock keeping unit (https://en.wikipedia.org/wiki/Stock_keeping_unit)
public type: WineType, // numerical rating, e.g., 1-100
public style: string, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: string, // light aromatic, textural, fruit forward, structural & savoury, powerful
public style: WineStyle, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: (
| WhiteWineCharacteristic
| AmberWineCharacteristic
| RoseWineCharacteristic
| RedWineCharacteristic
)[], // light aromatic, textural, fruit forward, structural & savoury, powerful
public country: Alpha2Code, // two-letter country codes defined in ISO 3166-1 (https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
public region: string, // appellation, village, sub-region, vineyard
public region: WineRegion, // appellation, village, sub-region, vineyard
public name: string, // label
public producerId: ObjectId, // product producer
public varietal: string, // if more than one, list as 'blend'
public vintage: Vintage, // year, nv (non-vintage) or mv (multi-vintage)
public grapeVarietal: GrapeVarietal[], // if more than one, list as 'blend'
public vintage: number | VintageOption, // year, nv (non-vintage) or mv (multi-vintage)
public volume: WineVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle
public viticulture: Viticulture, // two-letter country codes
public standardDrinks: StandardDrink, // an amount of standard drinks per 100ml and bottle in AU, UK and US
public viticulture: Viticulture, // viticulture
public sulfites: number, // parts per million
public filtered: boolean, // is wine filtered (fined (egg or fish))
public vegan: boolean,
public kosher: boolean,
public vegan: boolean, // if wine is vegan
public kosher: boolean, // if wine is kosher
public closure: BottleClosure, // cork, crown-seal, screwcap
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
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
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,16 @@
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'
// Global Config
export const coffeeRouter = express.Router()
coffeeRouter.use(express.json())
@ -25,22 +33,39 @@ coffeeRouter.get('/', async (_req: Request, res: Response) => {
// POST
coffeeRouter.post('/', async (req: Request, res: Response) => {
try {
const coffee = req.body as Coffee
const {
error,
value: coffee
}: { error: Joi.ValidationError | undefined; value: Coffee } =
coffeeValidation(req.body)
const result = await collections.coffee?.insertOne(coffee)
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) {
res
.status(201)
.send(`Successfully created a new coffee with id ${result.insertedId}`)
handleReqSuccess(
res,
DBinstance.Coffee,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
res.status(500).send('Failed to create a new coffee.')
}
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})

@ -1,9 +1,16 @@
// External Dependencies
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'
// Global Config
export const nostrRouter = express.Router()
nostrRouter.use(express.json())
@ -26,23 +33,51 @@ nostrRouter.get('/', async (_req: Request, res: Response) => {
// POST
nostrRouter.post('/', async (req: Request, res: Response) => {
try {
const nostrEvent = req.body as NostrEvent
const {
error,
value: event
}: { error: Joi.ValidationError | undefined; value: Event } =
nostrEventValidation(req.body)
if (error) {
throw error.details[0].message
}
const { id, pubkey, created_at, kind, tags, content } = event
const existingEvent = await collections.nostrEvents?.findOne({
nostrId: id
})
if (existingEvent) {
throw new Error('nostr event with provided "id" exists')
}
const nostrEvent: NostrEvent = {
nostrId: id,
pubkey,
created_at,
kind,
tags,
content
}
const result = await collections.nostrEvents?.insertOne(nostrEvent)
if (result) {
res
.status(201)
.send(
`Successfully created a new nostrEvent with id ${result.insertedId}`
)
handleReqSuccess(
res,
DBinstance.NostrEvent,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
res.status(500).send('Failed to create a new nostrEvent.')
}
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})
// TODO:
// Add delete route

@ -1,46 +1,307 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Review } from '../models'
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'
// Global Config
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) => {
try {
const reviews = await collections.reviews?.find({}).toArray()
res.status(200).send(reviews)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
reviewsRouter.post('/', async (req: Request, res: Response) => {
try {
const review = req.body as Review
const {
error,
value: review
}: { error: Joi.ValidationError | undefined; value: Review } =
reviewValidation(req.body)
const result = await collections.reviews?.insertOne(review)
if (error) {
throw error.details[0].message
}
if (result) {
res
.status(201)
.send(`Successfully created a new review with id ${result.insertedId}`)
// 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 {
res.status(500).send('Failed to create a new review.')
}
} catch (error: unknown) {
console.error(error)
review.reviewerId = user?._id
if (error instanceof Error) {
res.status(400).send(error.message)
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)
})

@ -1,46 +1,36 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Sake } from '../models'
import {
sakeValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
// Global Config
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) => {
try {
const sake = await collections.sake?.find({}).toArray()
res.status(200).send(sake)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
sakeRouter.post('/', async (req: Request, res: Response) => {
try {
const sake = req.body as Sake
const result = await collections.sake?.insertOne(sake)
if (result) {
res
.status(201)
.send(`Successfully created a new sake with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new sake.')
}
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
}
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)
})

@ -1,46 +1,48 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Spirit } from '../models'
import {
spiritValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
// Global Config
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) => {
try {
const spirits = await collections.spirits?.find({}).toArray()
res.status(200).send(spirits)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
spiritsRouter.post('/', async (req: Request, res: Response) => {
try {
const spirit = req.body as Spirit
const result = await collections.spirits?.insertOne(spirit)
if (result) {
res
.status(201)
.send(`Successfully created a new spirit with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new spirit.')
}
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
}
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,46 +1,106 @@
// External Dependencies
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
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)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
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.')
}
} catch (error) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
handleReqNotModified(res)
}
} 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)
}
})

@ -1,46 +1,42 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Wine } from '../models'
import {
wineValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance, UserRole } from '../types'
// Global Config
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) => {
try {
const wines = await collections.wines?.find({}).toArray()
res.status(200).send(wines)
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(500).send(error.message)
}
}
await handleGETreq(res, dbCollection, dbInstance)
})
// POST
winesRouter.post('/', async (req: Request, res: Response) => {
try {
const wine = req.body as Wine
const result = await collections.wines?.insertOne(wine)
if (result) {
res
.status(201)
.send(`Successfully created a new wine with id ${result.insertedId}`)
} else {
res.status(500).send('Failed to create a new wine.')
}
} catch (error: unknown) {
console.error(error)
if (error instanceof Error) {
res.status(400).send(error.message)
}
}
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,16 +1,16 @@
import * as mongoDB from 'mongodb'
import * as dotenv from 'dotenv'
import { DBcollections } from '../types'
import { DBcollection } from '../types'
// Global Variables
export const collections: {
[DBcollections.Users]?: mongoDB.Collection
[DBcollections.NostrEvents]?: mongoDB.Collection
[DBcollections.Reviews]?: mongoDB.Collection
[DBcollections.Wines]?: mongoDB.Collection
[DBcollections.Sake]?: mongoDB.Collection
[DBcollections.Spirits]?: mongoDB.Collection
[DBcollections.Coffee]?: mongoDB.Collection
[DBcollection.Users]?: mongoDB.Collection
[DBcollection.NostrEvents]?: mongoDB.Collection
[DBcollection.Reviews]?: mongoDB.Collection
[DBcollection.Wines]?: mongoDB.Collection
[DBcollection.Sake]?: mongoDB.Collection
[DBcollection.Spirits]?: mongoDB.Collection
[DBcollection.Coffee]?: mongoDB.Collection
} = {}
// Initialize Connection
@ -29,20 +29,20 @@ export async function connectToDatabase() {
const db: mongoDB.Db = client.db(process.env.DB_NAME)
const usersCollection: mongoDB.Collection = db.collection(DBcollections.Users)
const usersCollection: mongoDB.Collection = db.collection(DBcollection.Users)
const nostrEventsCollection: mongoDB.Collection = db.collection(
DBcollections.NostrEvents
DBcollection.NostrEvents
)
const reviewsCollection: mongoDB.Collection = db.collection(
DBcollections.Reviews
DBcollection.Reviews
)
const winesCollection: mongoDB.Collection = db.collection(DBcollections.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollections.Sake)
const winesCollection: mongoDB.Collection = db.collection(DBcollection.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollection.Sake)
const spiritsCollection: mongoDB.Collection = db.collection(
DBcollections.Spirits
DBcollection.Spirits
)
const coffeeCollection: mongoDB.Collection = db.collection(
DBcollections.Coffee
DBcollection.Coffee
)
collections.users = usersCollection

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

@ -1,38 +1,47 @@
export type CoffeeProcessingType =
| 'de-caff'
| 'honey'
| 'semi-dry'
| 'swiss water'
| 'sundried'
| 'washed'
type CoffeeVarietyType = 'Robusta' | 'Arabica'
type ArabicaVarietyKind =
| 'Typica'
| 'Bourbon'
| 'Caturra'
| 'Geisha'
| 'SL28'
| 'SL34'
| 'Maragogype'
| 'Pacas'
| 'Pacamara'
| 'Kona'
type RobustaVarietyKind =
| 'Congolese'
| 'Nganda'
| 'Kouillou'
| 'Vietnamese Robusta'
export type CoffeeVariety = {
[key in CoffeeVarietyType]?: ArabicaVarietyKind | RobustaVarietyKind
export enum CoffeeProcessingType {
DeCaff = 'De-caff',
Honey = 'Honey',
SemiDry = 'Semi-dry',
SwissWater = 'Swiss water',
Sundried = 'Sundried',
Washed = 'Washed'
}
export type CoffeeRoast =
| 'Light'
| 'Medium'
| 'Medium-Dark'
| 'Dark'
| 'Very Dark'
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'
}

@ -1,4 +1,4 @@
export enum DBcollections {
export enum DBcollection {
Users = 'users',
NostrEvents = 'nostrEvents',
Reviews = 'reviews',
@ -7,3 +7,13 @@ export enum DBcollections {
Spirits = 'spirits',
Coffee = 'coffee'
}
export enum DBinstance {
User = 'User',
NostrEvent = 'NostrEvent',
Review = 'Review',
Wine = 'Wine',
Sake = 'Sake',
Spirit = 'Spirit',
Coffee = 'Coffee'
}

@ -6,3 +6,6 @@ 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
}
}
}

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

@ -1,4 +1,4 @@
export enum Routes {
export enum Route {
Users = '/users',
NostrEvents = '/nostr',
Reviews = '/reviews',
@ -7,3 +7,20 @@ export enum Routes {
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'
}

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

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

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

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

38
src/utils/alcohol.ts Normal file

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

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