Merge pull request 'feat: added spirit validation' () from spirits-characteristics into staging

Reviewed-on: 
This commit is contained in:
Otto 2025-04-10 15:32:40 +00:00
commit 2a743043e0
15 changed files with 814 additions and 285 deletions

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

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

@ -3,12 +3,14 @@ import { collections } from '../services/database.service'
import { Wine } from '../models'
import {
wineValidation,
productCodeValidation,
handleReqError,
handleReqSuccess,
alcoholToStandardDrinks,
volumeToMl
} from '../utils'
import Joi from 'joi'
import { DBcollections, ProductType } from '../types'
export const winesRouter = express.Router()
@ -17,7 +19,7 @@ winesRouter.use(express.json())
// GET
winesRouter.get('/', async (_req: Request, res: Response) => {
try {
const wines = await collections.wines?.find({}).toArray()
const wines = await collections[DBcollections.Wines]?.find({}).toArray()
res.status(200).send(wines)
} catch (error: unknown) {
@ -45,46 +47,22 @@ winesRouter.post('/', async (req: Request, res: Response) => {
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')
}
}
await productCodeValidation(
productCodeEAN,
productCodeUPC,
productCodeSKU,
DBcollections.Wines,
ProductType.Wine
)
wine.standardDrinks = alcoholToStandardDrinks(
wine.alcohol,
volumeToMl(wine.volume)
)
const result = await collections.wines?.insertOne(wine)
const result = await collections[DBcollections.Wines]?.insertOne(wine)
handleReqSuccess(res, result, 'wine')
handleReqSuccess(res, result, ProductType.Wine)
} catch (error: unknown) {
handleReqError(res, error)
}

@ -1,65 +1,65 @@
export type Availability = 'In stock' | 'Out of stock' | 'Discontinued'
export type Ingredient =
| 'Blanche'
| 'Anise'
| 'Fennel'
| 'Hyssop'
| 'Mint'
| 'Citrus Peel'
| 'Coriander Seeds'
| 'Angelica Root'
| 'Cinnamon'
| 'Clove'
| 'Wheat'
| 'Rye'
| 'Corn'
| 'Potato'
| 'Barley'
| 'Sugarcane'
| 'Fruits'
| 'Grains'
| 'Juniper'
| 'Coriander'
| 'Lemon peel'
| 'Orange peel'
| 'Orris root'
| 'Cassia bark'
| 'Licorice root'
| 'Grapefruit peel'
| 'Elderflower'
| 'Apple'
| 'Blackcurrant'
| 'Butterscotch'
| 'Peach'
| 'Pear'
| 'Plum'
| 'Raspberries'
| 'Sorghum'
| 'Rice'
| 'Millet'
| 'Brown sugar'
| 'Buckwheat'
| 'Sweet Potato'
| 'Oat'
| 'Egg (Advocaat)'
| 'Strawberry'
| 'Almond'
| 'Banana'
| 'Chocolate'
| 'Sour Cherry'
| 'Violet'
| 'Lemon'
| 'Melon'
| 'Orange'
| 'Raspberry'
| 'Yuzu'
| 'Almond'
| 'Apricot Kernel'
| 'Hazelnut'
| 'Peanut'
| 'Pecan'
| 'Walnut'
export enum Ingredient {
Blanche = 'Blanche',
Anise = 'Anise',
Fennel = 'Fennel',
Hyssop = 'Hyssop',
Mint = 'Mint',
CitrusPeel = 'Citrus Peel',
CorianderSeeds = 'Coriander Seeds',
AngelicaRoot = 'Angelica Root',
Cinnamon = 'Cinnamon',
Clove = 'Clove',
Wheat = 'Wheat',
Rye = 'Rye',
Corn = 'Corn',
Potato = 'Potato',
Barley = 'Barley',
Sugarcane = 'Sugarcane',
Fruits = 'Fruits',
Grains = 'Grains',
Juniper = 'Juniper',
Coriander = 'Coriander',
LemonPeel = 'Lemon peel',
OrangePeel = 'Orange peel',
OrrisRoot = 'Orris root',
CassiaBark = 'Cassia bark',
LicoriceRoot = 'Licorice root',
GrapefruitPeel = 'Grapefruit peel',
Elderflower = 'Elderflower',
Apple = 'Apple',
Blackcurrant = 'Blackcurrant',
Butterscotch = 'Butterscotch',
Peach = 'Peach',
Pear = 'Pear',
Plum = 'Plum',
Raspberries = 'Raspberries',
Sorghum = 'Sorghum',
Rice = 'Rice',
Millet = 'Millet',
BrownSugar = 'Brown sugar',
Buckwheat = 'Buckwheat',
SweetPotato = 'Sweet Potato',
Oat = 'Oat',
EggAdvocaat = 'Egg (Advocaat)',
Strawberry = 'Strawberry',
Almond = 'Almond',
Banana = 'Banana',
Chocolate = 'Chocolate',
SourCherry = 'Sour Cherry',
Violet = 'Violet',
Lemon = 'Lemon',
Melon = 'Melon',
Orange = 'Orange',
Raspberry = 'Raspberry',
Yuzu = 'Yuzu',
ApricotKernel = 'Apricot Kernel',
Hazelnut = 'Hazelnut',
Peanut = 'Peanut',
Pecan = 'Pecan',
Walnut = 'Walnut'
}
export enum VintageOptions {
NV = 'NV',
@ -74,3 +74,10 @@ export interface StandardDrinks {
export enum RatingOptions {
NoScore = 'NS'
}
export enum ProductType {
Wine = 'Wine',
Spirit = 'Spirit',
Sake = 'Sake',
Coffee = 'Coffee'
}

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

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

@ -67,3 +67,5 @@ export enum WineVolume {
'6L' = '6L',
'12L' = '12L'
}
// retsina greek wine with pine essences should be considered a vermouth

@ -1,4 +1,4 @@
import { StandardDrinks, WineVolume } from '../types'
import { SpiritVolume, StandardDrinks, WineVolume } from '../types'
import { roundToOneDecimal } from './'
export const alcoholToStandardDrinks = (
@ -25,7 +25,7 @@ export const alcoholToStandardDrinks = (
}
}
export const volumeToMl = (volume: WineVolume): number => {
export const volumeToMl = (volume: WineVolume | SpiritVolume): number => {
if (volume.endsWith('L')) {
const volumeMl = volume.replace('L', '')

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

250
src/utils/spirit.ts Normal file

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

@ -2,3 +2,5 @@ export * from './user'
export * from './nostr'
export * from './review'
export * from './wine'
export * from './spirit'
export * from './product'

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

@ -0,0 +1,288 @@
import Joi from 'joi'
import {
Ingredient,
SpiritType,
SpiritVolume,
VintageOptions,
SpiritCharacteristics
} from '../../types'
import { isObject, spiritVariantMap, spiritCharacteristicsMap } from '../'
export const spiritValidation = (data: unknown): Joi.ValidationResult =>
Joi.object({
productCodeEAN: Joi.string().allow('').required(),
productCodeUPC: Joi.string().allow('').required(),
productCodeSKU: Joi.string().allow('').required(),
country: Joi.string().length(2),
region: Joi.string(),
name: Joi.string().required(),
producerId: Joi.string().length(24).required(),
type: Joi.string()
.valid(...Object.values(SpiritType))
.required(),
characteristics: Joi.array()
.items(
Joi.string().custom((characteristic, helper) => {
if (!Object.values(SpiritCharacteristics).includes(characteristic)) {
return helper.message({
custom: Joi.expression(
`"${characteristic}" is not a valid characteristic. Valid options are [${Object.values(
SpiritCharacteristics
)
.map((option) => `"${option}"`)
.join(', ')}]`
)
})
}
const spiritType: SpiritType = helper.state.ancestors[1].type
const spiritVariant: string | { [key: string]: unknown } =
helper.state.ancestors[1].variant
const spiritVariantName =
typeof spiritVariant === 'string'
? spiritVariant
: Object.keys(spiritVariant)[0]
const variantsInCharacteristic =
spiritCharacteristicsMap[characteristic as SpiritCharacteristics]
const characteristicsInVariant = Object.keys(
spiritCharacteristicsMap
).filter((char) =>
spiritCharacteristicsMap[char as SpiritCharacteristics].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(),
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
}
)
),
ingredients: Joi.array()
.items(Joi.string().valid(...Object.values(Ingredient)))
.required(),
volume: Joi.string()
.valid(...Object.values(SpiritVolume))
.required(),
alcohol: Joi.number().min(0).max(0.99).required(),
vintage: Joi.alternatives()
.try(
Joi.string().valid(...Object.values(VintageOptions)),
Joi.number().min(1000).max(new Date().getFullYear())
)
.required(),
RRPamount: Joi.number().required(),
RRPcurrency: Joi.string().length(3).required(),
description: Joi.string().required(),
url: Joi.string(),
image: Joi.string()
}).validate(data)

@ -25,7 +25,7 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
style: Joi.string()
.valid(...Object.values(WineStyle))
.required(),
characteristic: Joi.array()
characteristics: Joi.array()
.items(Joi.string())
.custom((value: string[], helper) => {
// return if no value
@ -97,6 +97,7 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
return value
}),
country: Joi.string().length(2),
// TODO: improve types
region: Joi.alternatives()
.try(
Joi.string().custom((value, helper) => {
@ -359,7 +360,10 @@ export const wineValidation = (data: unknown): Joi.ValidationResult =>
producerId: Joi.string().length(24).required(),
varietal: Joi.string().required(),
vintage: Joi.alternatives()
.try(Joi.string().valid(...Object.values(VintageOptions)), Joi.number())
.try(
Joi.string().valid(...Object.values(VintageOptions)),
Joi.number().min(1000).max(new Date().getFullYear())
)
.required(),
volume: Joi.string()
.valid(...Object.values(WineVolume))

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