feat(sake): added sake validation

This commit is contained in:
nostrdev-com 2025-04-11 17:48:23 +03:00
parent f6a09c1647
commit f1ca771574
11 changed files with 322 additions and 38 deletions

@ -52,6 +52,7 @@
"Tequilana",
"tobalá",
"Typica",
"Umami",
"UPC",
"Verte",
"VSOP",

@ -1,5 +1,13 @@
import { ObjectId } from 'mongodb'
import { SakeDesignation, SakeStarter, VintageOptions } from '../types'
import {
SakeDesignation,
SakeStarter,
VintageOptions,
SakeCharacteristics,
StandardDrinks,
SakeVolume,
RiceVarietal
} from '../types'
import { Alpha2Code } from 'i18n-iso-countries'
import { CurrencyCode } from 'currency-codes-ts/dist/types'
@ -12,12 +20,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, mirin: new/true/salt
public designation: SakeDesignation, // table, pure, blended
public polishRate: number, // %
public characteristics: SakeCharacteristics[],
public starter: SakeStarter, // sake starter
// FIXME
public yeastStrain: number,
public volume: SakeVolume, // bottle volume
public alcohol: number, // alcohol percentage
public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle per 100ml
public standardDrinks: StandardDrinks, // number representing an amount of standard drinks per bottle per 100ml
public riceVarietal: RiceVarietal[], // if more than one, list as 'blend'
// FIXME: Andrew will provide
public koji: string, // if more than one, list as 'blend'
public vintage: number | VintageOptions, // year, nv (non-vintage) or mv (multi-vintage)
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD

@ -21,13 +21,13 @@ 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 characteristics: SpiritCharacteristics, // 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 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 standardDrinks: StandardDrinks, // an amount of standard drinks per 100ml and bottle in AU, UK and US
public RRPamount: number, // 20
public RRPcurrency: CurrencyCode, // USD
public description: string, // detailed description of the product

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

@ -1,9 +1,80 @@
export type SakeDesignation =
| 'Table'
| 'Pure'
| 'Blended'
| 'Mirin:new'
| 'Mirin:true'
| 'Mirin:salt'
export enum SakeDesignation {
Table = 'Table',
Pure = 'Pure',
Blended = 'Blended' // Blended with Spirit - up to 10% of final volume
}
export type SakeStarter = 'Kimoto' | 'Sokujō' | 'Yamahai'
export enum TableSakeDesignation {
FutsūShu = 'Futsū-shu'
}
export enum PureSakeDesignation {
Junmai = 'Junmai',
JunmaiGinjo = 'Junmai Ginjo',
JunmaiDaiginjo = 'Junmai Daiginjo'
}
export enum BlendedSakeDesignation {
Honjozo = 'Honjozo',
Ginjo = 'Ginjo',
Daiginjo = 'Daiginjo'
}
export enum SakeStarter {
Kimoto = 'Kimoto',
Sokujō = 'Sokujō',
Yamahai = 'Yamahai'
}
export interface SakePolishMin {
min: number
}
export enum SakeCharacteristics {
LightAndRefreshing = 'Light and Refreshing',
CleanAndCrisp = 'Clean and Crisp',
FruityAndAromatic = 'Fruity and Aromatic',
RichAndUmami = 'Rich and Umami',
ComplexAndLayered = 'Complex and Layered',
RobustAndFullBodied = 'Robust and Full-Bodied'
}
export enum SakeVolume {
'0.18L' = '0.18L',
'0.3L' = '0.3L',
'0.5L' = '0.5L',
'0.72L' = '0.72L',
'1.8L' = '1.8L',
'3.6L' = '3.6L'
}
export enum RiceVarietal {
Blended = 'BLENDED',
AkitaSakeKomachi = 'Akita Sake Komachi',
Akitakomachi = 'Akitakomachi',
DewaSansan = 'Dewa Sansan',
Ginnosei = 'Ginnosei',
Gohyakumangoku = 'Gohyakumangoku',
HanaFubuki = 'Hana-Fubuki',
HattanNishiki = 'Hattan-Nishiki',
Hinohikari = 'Hinohikari',
Hitomebore = 'Hitomebore',
HyogoKitaNishiki = 'Hyogo Kita Nishiki',
Ibaraki5 = 'Ibaraki 5',
KairyōMai = 'Kairyō-mai',
KitaNishiki = 'Kita Nishiki',
KokuryūMai = 'Kokuryū-mai',
MiyamaNishiki = 'Miyama Nishiki',
NakateShinseiki = 'Nakate Shinseiki',
NiigataKoshihikari = 'Niigata Koshihikari',
Notohikari = 'Notohikari',
Ōmachi = 'Ōmachi',
Sakamai = 'Sakamai',
Sankei65 = 'Sankei 65',
Shinriki = 'Shinriki',
Tamazakae = 'Tamazakae',
Tōkai14 = 'Tōkai 14',
YamadaNishiki = 'Yamada Nishiki',
Yamagata4 = 'Yamagata 4',
YumeIkkon = 'Yume-Ikkon'
}

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

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

36
src/utils/sake.ts Normal file

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

@ -73,13 +73,13 @@ export const spiritVariantMap: {
],
[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)'
'Apple',
'Blackcurrant',
'Butterscotch',
'Peach',
'Pear',
'Plum',
'Raspberries'
],
[WhiteSpiritVariants.Grappa]: ['Marc', 'Pisco'],
[WhiteSpiritVariants.Baijiu]: [
@ -149,7 +149,6 @@ export const spiritVariantMap: {
[LiqueursSpiritVariants.Coffee]: [],
[LiqueursSpiritVariants.Cream]: [
'Egg (Advocaat)',
'Amarula',
'Rum',
'Strawberry',
'Whiskey (Baileys etc)'
@ -190,7 +189,8 @@ export const spiritVariantMap: {
'Hazelnut',
'Peanut',
'Pecan',
'Walnut'
'Walnut',
'Amarula'
]
}
}

@ -5,3 +5,4 @@ export * from './wine'
export * from './spirit'
export * from './product'
export * from './validations'
export * from './sake'

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