parent
f6a09c1647
commit
f1ca771574
.vscode
src
models
routes
types
utils
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -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
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'
|
||||
|
134
src/utils/validation/sake.ts
Normal file
134
src/utils/validation/sake.ts
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user