Compare commits

..

No commits in common. "staging" and "payload-validation" have entirely different histories.

64 changed files with 640 additions and 5267 deletions

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

85
package-lock.json generated

@ -7,11 +7,8 @@
"": {
"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",
@ -28,9 +25,8 @@
"@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.14.1",
"@types/node": "^22.13.12",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"globals": "^16.0.0",
@ -435,27 +431,24 @@
}
},
"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==",
"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.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
"@noble/hashes": "1.3.2"
},
"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==",
"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": "^14.21.3 || >=16"
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
@ -473,15 +466,6 @@
"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",
@ -1686,13 +1670,6 @@
"@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",
@ -1782,13 +1759,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"version": "22.13.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.12.tgz",
"integrity": "sha512-ixiWrCSRi33uqBMRuICcKECW7rtgY43TbsHDpM2XK7lXispd48opW+0IXrBVxv9NMhaz/Ue9kyj6r3NTVyXm8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
"undici-types": "~6.20.0"
}
},
"node_modules/@types/normalize-package-data": {
@ -3101,12 +3078,6 @@
"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",
@ -6463,30 +6434,6 @@
}
}
},
"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",
@ -11564,9 +11511,9 @@
"license": "MIT"
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
},

@ -27,8 +27,6 @@
"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",
@ -45,9 +43,8 @@
"@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.14.1",
"@types/node": "^22.13.12",
"concurrently": "^9.1.2",
"eslint": "^9.23.0",
"globals": "^16.0.0",

@ -10,8 +10,7 @@ import {
spiritsRouter,
coffeeRouter
} from './routes'
import { Route } from './types'
import { authorizeRequest } from './middlewares'
import { Routes } from './types'
dotenv.config()
@ -20,15 +19,13 @@ const port = process.env.PORT || 3000
connectToDatabase()
.then(() => {
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.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.listen(port, () => {
console.log(`Server started at http://localhost:${port}`)

@ -1,71 +0,0 @@
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 +0,0 @@
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 name: string, // label
public producerId: ObjectId, // product producer
public variety: CoffeeVariety, // variety type and kind
public processingType: CoffeeProcessingType, // processing type
public name: string, // label
public producerId: ObjectId, // product producer
public roast: CoffeeRoast, // roast level
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD

@ -7,6 +7,6 @@ export class NostrEvent {
public kind: number, // event type, e.g., review, article, comment
public tags: string[][], // array of keywords or hashtags
public content: string, // text content of the event
public _id?: ObjectId // database object id
public id?: ObjectId // database object id
) {}
}

@ -1,17 +1,12 @@
import { ObjectId } from 'mongodb'
import { ProductType, RatingOption, TastingNote } from '../types'
export class Review {
constructor(
public nostrId: string, // unique identifier for the Nostr Event
public nostrEventId: ObjectId, // unique identifier for the NostrEvent DB instance
public productId: ObjectId, // unique identifier for the Product DB instance
public reviewerId: ObjectId, // unique identifier for the User DB instance
public productType: ProductType, // product type
public rating: number | RatingOption, // numerical rating, e.g., 84-100 or NS (no score)
public 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 reviewText: string, // text content of the review
public tastingNote: TastingNote, // an object representing tasting notes
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
public tastingNotes: string[], // array of tasting notes, e.g., flavours, aromas
public id?: ObjectId // database object id
) {}
}

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

@ -1,13 +1,5 @@
import { ObjectId } from 'mongodb'
import {
SpiritType,
SpiritVariant,
Ingredient,
VintageOption,
StandardDrink,
SpiritVolume,
SpiritCharacteristic
} from '../types'
import { SpiritType, SpiritVariant, Ingredient, VintageOptions } from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -22,18 +14,15 @@ 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 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 standardDrinks100ml: number, // number representing an amount of standard drinks per bottle
public vintage: number | VintageOptions, // year, nv (non-vintage) or mv (multi-vintage)
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product
public url?: string, // e.g. producer's website
public images?: string[], // (optional image URL)cellar.social
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
public image?: string, // (optional image URL)cellar.social
public id?: ObjectId // database object id
) {}
}

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

@ -3,16 +3,10 @@ import {
WineType,
Viticulture,
BottleClosure,
VintageOption,
StandardDrink,
VintageOptions,
StandardDrinks,
WineRegion,
WineVolume,
WineStyle,
WhiteWineCharacteristic,
AmberWineCharacteristic,
RoseWineCharacteristic,
RedWineCharacteristic,
GrapeVarietal
WineVolume
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -23,22 +17,17 @@ 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: WineStyle, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: (
| WhiteWineCharacteristic
| AmberWineCharacteristic
| RoseWineCharacteristic
| RedWineCharacteristic
)[], // light aromatic, textural, fruit forward, structural & savoury, powerful
public style: string, // bubbles+fizz, table, dessert, fortified, vermouth
public characteristic: string, // light aromatic, textural, fruit forward, structural & savoury, powerful
public country: Alpha2Code, // two-letter country codes defined in ISO 3166-1 (https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)
public region: WineRegion, // appellation, village, sub-region, vineyard
public name: string, // label
public producerId: ObjectId, // product producer
public grapeVarietal: GrapeVarietal[], // if more than one, list as 'blend'
public vintage: number | VintageOption, // year, nv (non-vintage) or mv (multi-vintage)
public varietal: string, // if more than one, list as 'blend'
public vintage: number | VintageOptions, // year, nv (non-vintage) or mv (multi-vintage)
public volume: WineVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks: StandardDrink, // an amount of standard drinks per 100ml and bottle in AU, UK and US
public standardDrinks: StandardDrinks, // 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))
@ -49,8 +38,7 @@ export class Wine {
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product
public url?: string, // e.g. producer's website
public images?: string[], // (optional image URL)cellar.social
public _id?: ObjectId, // database object id
public id?: string // string representing database object id
public image?: string, // (optional image URL)cellar.social
public id?: ObjectId // database object id
) {}
}

@ -1,15 +1,6 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Coffee } from '../models'
import {
coffeeValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified
} from '../utils'
import Joi from 'joi'
import { DBcollection, DBinstance, HTTPmethod, ResponseStatus } from '../types'
export const coffeeRouter = express.Router()
@ -33,39 +24,22 @@ coffeeRouter.get('/', async (_req: Request, res: Response) => {
// POST
coffeeRouter.post('/', async (req: Request, res: Response) => {
try {
const {
error,
value: coffee
}: { error: Joi.ValidationError | undefined; value: Coffee } =
coffeeValidation(req.body)
const coffee = req.body as 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)
const result = await collections.coffee?.insertOne(coffee)
if (result) {
handleReqSuccess(
res,
DBinstance.Coffee,
result.insertedId.toString(),
HTTPmethod.POST
)
res
.status(201)
.send(`Successfully created a new coffee with id ${result.insertedId}`)
} else {
handleReqNotModified(res)
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)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
}
})

@ -4,12 +4,10 @@ import { NostrEvent } from '../models'
import {
nostrEventValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified
handleReqSuccess
} from '../utils'
import { Event } from 'nostr-tools'
import Joi from 'joi'
import { DBinstance, HTTPmethod, ResponseStatus } from '../types'
export const nostrRouter = express.Router()
@ -64,20 +62,8 @@ nostrRouter.post('/', async (req: Request, res: Response) => {
const result = await collections.nostrEvents?.insertOne(nostrEvent)
if (result) {
handleReqSuccess(
res,
DBinstance.NostrEvent,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqNotModified(res)
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
handleReqSuccess(res, result, 'nostrEvent')
} catch (error: unknown) {
handleReqError(res, error)
}
})
// TODO:
// Add delete route

@ -1,41 +1,26 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Review, Sake, Spirit, User, Wine, NostrEvent } from '../models'
import {
reviewValidation,
handleReqError,
handleReqSuccess,
handleReqNotModified,
handleGETreq,
handleReqUnauthorized,
handleReqNotFound,
compareObjects,
idValidation,
modificationPeriodExpired,
handleProductDELETEreq
} from '../utils'
import { Review } from '../models'
import { reviewValidation, handleReqError, handleReqSuccess } from '../utils'
import Joi from 'joi'
import {
DBcollection,
HTTPmethod,
ResponseStatus,
TastingNoteKey,
DBinstance,
UserRole,
ProductType
} from '../types'
import { ObjectId } from 'mongodb'
export const reviewsRouter = express.Router()
const dbInstance = DBinstance.Review
const dbCollection = DBcollection.Reviews
reviewsRouter.use(express.json())
// GET
reviewsRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
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)
}
}
})
// POST
@ -51,257 +36,18 @@ reviewsRouter.post('/', async (req: Request, res: Response) => {
throw error.details[0].message
}
// user check
const { userId } = res.locals
const user = await collections[DBcollection.Users]?.findOne<User>({
_id: userId
const existingReview = await collections.reviews?.findOne({
eventId: review.eventId
})
if ((user && user.role !== UserRole.Reviewer) || !user?._id) {
handleReqUnauthorized(res)
} else {
review.reviewerId = user?._id
const { nostrId, productId, productType } = review
// nostr event check
const existingNostrEvent = await collections[
DBcollection.NostrEvents
]?.findOne<NostrEvent>({
nostrId
})
if (!existingNostrEvent || !existingNostrEvent._id) {
throw new Error('associated nostr event not found')
}
review.nostrEventId = existingNostrEvent._id
const existingReviewWithNostrEventId = await collections[
dbCollection
]?.findOne({
nostrEventId: existingNostrEvent._id
})
if (existingReviewWithNostrEventId) {
throw new Error('review with provided "nostrEventId" exists')
}
let product: Wine | Sake | Spirit | null | undefined = undefined
switch (productType) {
case ProductType.Wine:
product = await collections[DBcollection.Wines]?.findOne<Wine>({
_id: new ObjectId(productId)
})
break
case ProductType.Sake:
product = await collections[DBcollection.Sake]?.findOne<Sake>({
_id: new ObjectId(productId)
})
break
case ProductType.Spirit:
product = await collections[DBcollection.Spirits]?.findOne<Spirit>({
_id: new ObjectId(productId)
})
break
default:
break
}
if (!product || !product._id) {
throw new Error('product not found')
}
review.productId = product._id
// Add age property to tasting note, if product has vintage property
const { vintage } = product
if (typeof vintage === 'number') {
review.tastingNote[TastingNoteKey.TextureAndBalance].age =
new Date().getFullYear() - vintage
}
delete review.id
const result = await collections[dbCollection]?.insertOne(review)
if (result) {
handleReqSuccess(
res,
dbInstance,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
handleReqNotModified(res)
}
if (existingReview) {
throw new Error('review with provided "eventId" exists')
}
} catch (err) {
handleReqError(res, err, ResponseStatus.BadRequest)
const result = await collections.reviews?.insertOne(review)
handleReqSuccess(res, result, 'review')
} catch (error: unknown) {
handleReqError(res, error)
}
})
// 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,36 +1,45 @@
import express, { Request, Response } from 'express'
import {
sakeValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
import { collections } from '../services/database.service'
import { Sake } from '../models'
export const sakeRouter = express.Router()
const dbInstance = DBinstance.Sake
const dbCollection = DBcollection.Sake
sakeRouter.use(express.json())
// GET
sakeRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
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)
}
}
})
// POST
sakeRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(req, res, dbCollection, dbInstance, sakeValidation)
})
try {
const sake = req.body as Sake
// PUT
sakeRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(req, res, dbCollection, dbInstance, sakeValidation)
})
const result = await collections.sake?.insertOne(sake)
// DELETE
sakeRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(req, res, dbCollection, dbInstance)
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)
}
}
})

@ -1,48 +1,45 @@
import express, { Request, Response } from 'express'
import {
spiritValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
} from '../utils'
import { DBcollection, DBinstance } from '../types'
import { collections } from '../services/database.service'
import { Spirit } from '../models'
export const spiritsRouter = express.Router()
const dbInstance = DBinstance.Spirit
const dbCollection = DBcollection.Spirits
spiritsRouter.use(express.json())
// GET
spiritsRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
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)
}
}
})
// POST
spiritsRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(
req,
res,
dbCollection,
dbInstance,
spiritValidation
)
})
try {
const spirit = req.body as Spirit
// PUT
spiritsRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(
req,
res,
dbCollection,
dbInstance,
spiritValidation
)
})
const result = await collections.spirits?.insertOne(spirit)
// DELETE
spiritsRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(req, res, dbCollection, dbInstance)
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)
}
}
})

@ -1,26 +1,26 @@
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 { userValidation, handleReqError, handleReqSuccess } from '../utils'
import Joi from 'joi'
import { DBcollection, DBinstance, HTTPmethod, ResponseStatus } from '../types'
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) => {
await handleGETreq(res, dbCollection, dbInstance)
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)
}
}
})
// POST
@ -37,70 +37,18 @@ usersRouter.post('/', async (req: Request, res: Response) => {
throw error.details[0].message
}
const { npub } = res.locals
const existingUser = await collections[dbCollection]?.findOne({
npub
const existingUser = await collections.users?.findOne({
npub: newUser.npub
})
if (existingUser) {
throw new Error('user with provided "npub" exists')
}
newUser.npub = npub
const result = await collections.users?.insertOne(newUser)
const result = await collections[dbCollection]?.insertOne(newUser)
if (result) {
handleReqSuccess(
res,
DBinstance.User,
result.insertedId.toString(),
HTTPmethod.POST
)
} else {
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)
handleReqSuccess(res, result, 'user')
} catch (error) {
handleReqError(res, error)
}
})

@ -1,42 +1,91 @@
import express, { Request, Response } from 'express'
import { collections } from '../services/database.service'
import { Wine } from '../models'
import {
wineValidation,
handleGETreq,
handleProductPOSTreq,
handleProductPUTreq,
handleProductDELETEreq
handleReqError,
handleReqSuccess,
alcoholToStandardDrinks,
volumeToMl
} from '../utils'
import { DBcollection, DBinstance, UserRole } from '../types'
import Joi from 'joi'
export const winesRouter = express.Router()
const dbInstance = DBinstance.Wine
const dbCollection = DBcollection.Wines
winesRouter.use(express.json())
// GET
winesRouter.get('/', async (_req: Request, res: Response) => {
await handleGETreq(res, dbCollection, dbInstance)
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)
}
}
})
// POST
winesRouter.post('/', async (req: Request, res: Response) => {
await handleProductPOSTreq(req, res, dbCollection, dbInstance, wineValidation)
})
try {
const {
error,
value: wine
}: { error: Joi.ValidationError | undefined; value: Wine } = wineValidation(
req.body
)
// PUT
winesRouter.put('/', async (req: Request, res: Response) => {
await handleProductPUTreq(req, res, dbCollection, dbInstance, wineValidation)
})
if (error) {
throw error.details[0].message
}
// DELETE
winesRouter.delete('/', async (req: Request, res: Response) => {
await handleProductDELETEreq(
req,
res,
dbCollection,
dbInstance,
UserRole.Reviewer
)
const { productCodeEAN, productCodeUPC, productCodeSKU } = wine
if (!productCodeEAN && !productCodeUPC && !productCodeSKU) {
throw new Error(
'provide "productCodeEAN", "productCodeUPC" or "productCodeSKU"'
)
}
if (productCodeEAN) {
const existingWine = await collections.wines?.findOne({
productCodeEAN
})
if (existingWine) {
throw new Error('wine with provided "productCodeEAN" exists')
}
} else if (productCodeUPC) {
const existingWine = await collections.wines?.findOne({
productCodeUPC
})
if (existingWine) {
throw new Error('wine with provided "productCodeUPC" exists')
}
} else {
const existingWine = await collections.wines?.findOne({
productCodeSKU
})
if (existingWine) {
throw new Error('wine with provided "productCodeSKU" exists')
}
}
wine.standardDrinks = alcoholToStandardDrinks(
wine.alcohol,
volumeToMl(wine.volume)
)
const result = await collections.wines?.insertOne(wine)
handleReqSuccess(res, result, 'wine')
} catch (error: unknown) {
handleReqError(res, error)
}
})

@ -1,16 +1,16 @@
import * as mongoDB from 'mongodb'
import * as dotenv from 'dotenv'
import { DBcollection } from '../types'
import { DBcollections } from '../types'
// Global Variables
export const collections: {
[DBcollection.Users]?: mongoDB.Collection
[DBcollection.NostrEvents]?: mongoDB.Collection
[DBcollection.Reviews]?: mongoDB.Collection
[DBcollection.Wines]?: mongoDB.Collection
[DBcollection.Sake]?: mongoDB.Collection
[DBcollection.Spirits]?: mongoDB.Collection
[DBcollection.Coffee]?: mongoDB.Collection
[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
} = {}
// 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(DBcollection.Users)
const usersCollection: mongoDB.Collection = db.collection(DBcollections.Users)
const nostrEventsCollection: mongoDB.Collection = db.collection(
DBcollection.NostrEvents
DBcollections.NostrEvents
)
const reviewsCollection: mongoDB.Collection = db.collection(
DBcollection.Reviews
DBcollections.Reviews
)
const winesCollection: mongoDB.Collection = db.collection(DBcollection.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollection.Sake)
const winesCollection: mongoDB.Collection = db.collection(DBcollections.Wines)
const sakeCollection: mongoDB.Collection = db.collection(DBcollections.Sake)
const spiritsCollection: mongoDB.Collection = db.collection(
DBcollection.Spirits
DBcollections.Spirits
)
const coffeeCollection: mongoDB.Collection = db.collection(
DBcollection.Coffee
DBcollections.Coffee
)
collections.users = usersCollection

@ -1,14 +0,0 @@
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,47 +1,38 @@
export enum CoffeeProcessingType {
DeCaff = 'De-caff',
Honey = 'Honey',
SemiDry = 'Semi-dry',
SwissWater = 'Swiss water',
Sundried = 'Sundried',
Washed = 'Washed'
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 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'
}
export type CoffeeRoast =
| 'Light'
| 'Medium'
| 'Medium-Dark'
| 'Dark'
| 'Very Dark'

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

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

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

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

@ -1,122 +0,0 @@
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
}
}

@ -1,43 +0,0 @@
// 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'
}

@ -1,63 +0,0 @@
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'
}

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

@ -1,213 +0,0 @@
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'
}

@ -1,95 +0,0 @@
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
}

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

@ -1,140 +0,0 @@
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
}

@ -1,206 +0,0 @@
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
}

@ -1,257 +0,0 @@
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
}

@ -1,106 +0,0 @@
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'
}

@ -1,17 +0,0 @@
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 Route {
export enum Routes {
Users = '/users',
NostrEvents = '/nostr',
Reviews = '/reviews',
@ -7,20 +7,3 @@ export enum Route {
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,130 +1,9 @@
export enum SakeDesignation {
Table = 'Table',
Pure = 'Pure',
Blended = 'Blended' // Blended with Spirit - up to 10% of final volume
}
export type SakeDesignation =
| 'table'
| 'pure'
| 'blended'
| 'mirin:new'
| 'mirin:true'
| 'mirin:salt'
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'
}
export type SakeStarter = 'Kimoto' | 'Sokujō' | 'Yamahai'

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

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

@ -1,53 +1,20 @@
export enum WineType {
White = 'White',
Amber = 'Amber',
Rose = 'Rose',
Red = 'Red'
}
export enum WhiteWineCharacteristic {
LightAromatic = 'Light Aromatic',
TexturalAndSavory = 'Textural and Savory',
RichAndFruitForward = 'Rich and Fruit Forward'
}
export enum AmberWineCharacteristic {
TexturalAndAromatic = 'Textural and Aromatic',
StructuralAndSavory = 'Structural and Savory',
PowerAndPresence = 'Power and Presence'
}
export enum RoseWineCharacteristic {
LightAndFruitForward = 'Light and Fruit Forward',
TexturalAndSavory = 'Textural and Savory',
StructuralAndAromatic = 'Structural and Aromatic'
}
export enum RedWineCharacteristic {
LightAndFruitForward = 'Light and Fruit Forward',
StructuralAndAromatic = 'Structural and Aromatic',
TexturalAndSavory = 'Textural and Savory',
PowerAndPresence = 'Power and Presence'
}
export enum WineStyle {
BubblesAndFizz = 'Bubbles + Fizz',
Table = 'Table',
Dessert = 'Dessert',
Fortified = 'Fortified',
Vermouth = 'Vermouth'
White = 'white',
Amber = 'amber',
Rose = 'rose',
Red = 'red'
}
export enum Viticulture {
Biodynamic = 'Biodynamic',
Organic = 'Organic',
Conventional = 'Conventional'
Biodynamic = 'biodynamic',
Organic = 'organic',
Conventional = 'conventional'
}
export enum BottleClosure {
Cork = 'Cork',
CrownSeal = 'Crown-seal',
Screwcap = 'Screwcap'
Cork = 'cork',
CrownSeal = 'crown-seal',
Screwcap = 'screwcap'
}
export interface WineRegion {
@ -67,292 +34,3 @@ export enum WineVolume {
'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

@ -1,10 +1,10 @@
import { SakeVolume, SpiritVolume, StandardDrink, WineVolume } from '../types'
import { StandardDrinks, WineVolume } from '../types'
import { roundToOneDecimal } from './'
export const alcoholToStandardDrinks = (
alcohol: number,
bottle: number
): StandardDrink => {
): StandardDrinks => {
const UK100ml = roundToOneDecimal(10 * alcohol)
const AU100ml = roundToOneDecimal(7.91 * alcohol)
const US100ml = roundToOneDecimal(5.64 * alcohol)
@ -25,9 +25,7 @@ export const alcoholToStandardDrinks = (
}
}
export const volumeToMl = (
volume: WineVolume | SpiritVolume | SakeVolume
): number => {
export const volumeToMl = (volume: WineVolume): number => {
if (volume.endsWith('L')) {
const volumeMl = volume.replace('L', '')

@ -1,7 +0,0 @@
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 +0,0 @@
export const MODIFICATION_PERIOD = 24 * 60 * 60 * 1000 // 24h in milliseconds

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

@ -4,6 +4,3 @@ export * from './route'
export * from './utils'
export * from './alcohol'
export * from './wine'
export * from './spirit'
export * from './sake'
export * from './const'

@ -1,8 +1,4 @@
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
@ -36,38 +32,3 @@ export const npubToHex = (pubKey: string): string | null => {
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)
}

@ -1,312 +1,28 @@
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'
import { Response } from 'express'
import { InsertOneResult } from 'mongodb'
export const handleGETreq = async (
res: Response,
dbCollection: DBcollection,
item: DBinstance
) => {
try {
const items = await collections[dbCollection]?.find({}).toArray()
export const handleReqError = (res: Response, error: unknown) => {
console.error(error)
handleReqSuccess(res, item, '', HTTPmethod.GET, items)
} catch (err) {
handleReqError(res, err, ResponseStatus.InternalServerError)
if (error instanceof Error) {
res.status(400).send(error.message)
} else if (typeof error === 'string') {
res.status(400).send(error)
}
}
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
result: InsertOneResult<Document> | undefined,
itemName: string
) => {
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
if (result) {
res
.status(201)
.send(
`Successfully created a new ${itemName} with id ${result.insertedId}`
)
} else {
res.status(500).send(`Failed to create a new ${itemName}.`)
}
}
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`)

@ -1,36 +0,0 @@
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 }
}
}

@ -1,251 +0,0 @@
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
]
}

@ -1,18 +1,5 @@
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

@ -1,110 +0,0 @@
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)

@ -1,10 +1,4 @@
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'

@ -1,53 +0,0 @@
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)
}
}
}

@ -1,942 +1,10 @@
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(),
eventId: Joi.string().required(),
productId: Joi.string().required(),
rating: Joi.number().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()
tastingNotes: Joi.array().items(Joi.string())
}).validate(data)

@ -1,137 +0,0 @@
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)

@ -1,293 +0,0 @@
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)

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

@ -1,190 +0,0 @@
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')
}
}

@ -1,44 +1,25 @@
import Joi from 'joi'
import {
WineType,
VintageOptions,
BottleClosure,
Viticulture,
WineRegion,
WineVolume,
WineStyle,
WhiteWineCharacteristic,
AmberWineCharacteristic,
RoseWineCharacteristic,
RedWineCharacteristic,
GrapeVarietal
WineVolume
} 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
productCodeEAN: Joi.string().allow('').required(),
productCodeUPC: Joi.string().allow('').required(),
productCodeSKU: Joi.string().allow('').required(),
type: Joi.string()
.valid(...Object.values(WineType))
.required(),
style: Joi.string().required(),
characteristic: Joi.string().required(),
country: Joi.string().length(2),
region: Joi.alternatives()
.try(
Joi.string().custom((value, helper) => {
@ -125,9 +106,7 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
): 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`
`"region" contains an invalid value. Valid values for ${map.join(' -> ')} are [${options.join(', ')}]`
)
})
@ -187,24 +166,26 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
/**
* 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 villages: string[] = Array.isArray(
(
(regionMap as WineRegion)[providedRegion] as {
[key: string]: string | { [key: string]: string[] }
}
)[providedSubRegionName]
)
? ((
(regionMap as WineRegion)[providedRegion] as {
[key: string]: string | { [key: string]: string[] }
}
)[providedSubRegionName] as unknown as string[])
: Object.keys(
(
(regionMap as WineRegion)[providedRegion] as {
[key: string]: string | { [key: string]: string[] }
}
)[providedSubRegionName]
)
const providedVillage: string | { [key: string]: string } = (
value[providedRegion] as {
@ -260,14 +241,6 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
}
)[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]: {
@ -297,84 +270,16 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
)
.allow('')
.required(),
name: nameValidation,
type: typeValidation(WineType),
style: Joi.string()
.valid(...Object.values(WineStyle))
name: Joi.string().required(),
producerId: Joi.string().length(24).required(),
varietal: Joi.string().required(),
vintage: Joi.alternatives()
.try(Joi.string().valid(...Object.values(VintageOptions)), Joi.number())
.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)))
volume: Joi.string()
.valid(...Object.values(WineVolume))
.required(),
vintage: vintageValidation,
alcohol: Joi.number().min(0).max(0.99).required(),
viticulture: Joi.string()
.valid(...Object.values(Viticulture))
.required(),
@ -385,9 +290,9 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
closure: Joi.string()
.valid(...Object.values(BottleClosure))
.required(),
RRPamount: RRPamountValidation,
RRPcurrency: RRPcurrencyValidation,
description: descriptionValidation,
url: urlValidation,
images: imagesValidation
RRPamount: Joi.number().required(),
RRPcurrency: Joi.string().length(3).required(),
description: Joi.string().required(),
url: Joi.string(),
image: Joi.string()
}).validate(data)

@ -389,13 +389,13 @@ export const wineRegionsMap: { [key: string]: string[] | WineRegion } = {
'Bitlis',
'İzmir',
'Manisa',
'Ayvalik'
'Ayvalık'
],
'Aegean Region': ['Urla', 'Foça', 'Bornova'],
Ayvalik: [],
Ayvalık: [],
'Marmara Region': ['İstanbul', 'Edirne'],
'Black Sea Region': ['Rize', 'Artvin', 'Trabzon'],
'Southeastern Anatolia': ['Gaziantep', 'Şanliurfa', 'Adana']
'Southeastern Anatolia': ['Gaziantep', 'Şanlıurfa', 'Adana']
},
AM: ['Vayots Dzor', 'Ararat Valley', 'Gegharkunik', 'Tavush', 'Syunik'],
CA: {