From b288e40d13579df4ed717403198a398ce023f460 Mon Sep 17 00:00:00 2001
From: nostrdev-com <support@nostrdev.com>
Date: Thu, 3 Apr 2025 10:16:55 +0300
Subject: [PATCH] feat(wine): added payload validation

---
 src/models/wine.ts            | 14 +++++---
 src/routes/wines.router.ts    | 68 ++++++++++++++++++++++++++++-------
 src/types/product.ts          | 11 +++++-
 src/types/wine.ts             | 19 ++++++++--
 src/utils/alcohol.ts          | 10 ++++++
 src/utils/index.ts            |  2 ++
 src/utils/utils.ts            |  2 ++
 src/utils/validation/index.ts |  1 +
 src/utils/validation/wine.ts  | 43 ++++++++++++++++++++++
 9 files changed, 149 insertions(+), 21 deletions(-)
 create mode 100644 src/utils/alcohol.ts
 create mode 100644 src/utils/utils.ts
 create mode 100644 src/utils/validation/wine.ts

diff --git a/src/models/wine.ts b/src/models/wine.ts
index 4a1ab9c..8df38df 100644
--- a/src/models/wine.ts
+++ b/src/models/wine.ts
@@ -1,5 +1,11 @@
 import { ObjectId } from 'mongodb'
-import { WineType, Viticulture, BottleClosure, Vintage } from '../types'
+import {
+  WineType,
+  Viticulture,
+  BottleClosure,
+  VintageOptions,
+  StandardDrinks100ml
+} from '../types'
 import { Alpha2Code } from 'i18n-iso-countries'
 import { CurrencyCode } from 'currency-codes-ts/dist/types'
 
@@ -16,10 +22,10 @@ export class Wine {
     public name: string, // label
     public producerId: ObjectId, // product producer
     public varietal: string, // if more than one, list as 'blend'
-    public vintage: Vintage, // year, nv (non-vintage) or mv (multi-vintage)
+    public vintage: number | VintageOptions, // year, nv (non-vintage) or mv (multi-vintage)
     public alcohol: number, // alcohol percentage
-    public standardDrinks100ml: number, // number representing an amount of standard drinks per bottle
-    public viticulture: Viticulture, // two-letter country codes
+    public standardDrinks100ml: StandardDrinks100ml, // an amount of standard drinks per 100ml in AU, UK and US
+    public viticulture: Viticulture, // viticulture
     public sulfites: number, // parts per million
     public filtered: boolean, // is wine filtered (fined (egg or fish))
     public vegan: boolean, // if wine is vegan
diff --git a/src/routes/wines.router.ts b/src/routes/wines.router.ts
index e83e397..b7ca3f3 100644
--- a/src/routes/wines.router.ts
+++ b/src/routes/wines.router.ts
@@ -1,6 +1,13 @@
 import express, { Request, Response } from 'express'
 import { collections } from '../services/database.service'
 import { Wine } from '../models'
+import {
+  wineValidation,
+  handleReqError,
+  handleReqSuccess,
+  alcoholToStandardDrinks
+} from '../utils'
+import Joi from 'joi'
 
 export const winesRouter = express.Router()
 
@@ -24,22 +31,57 @@ winesRouter.get('/', async (_req: Request, res: Response) => {
 // POST
 winesRouter.post('/', async (req: Request, res: Response) => {
   try {
-    const wine = req.body as Wine
+    const {
+      error,
+      value: wine
+    }: { error: Joi.ValidationError | undefined; value: Wine } = wineValidation(
+      req.body
+    )
+
+    if (error) {
+      throw error.details[0].message
+    }
+
+    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.standardDrinks100ml = alcoholToStandardDrinks(wine.alcohol)
 
     const result = await collections.wines?.insertOne(wine)
 
-    if (result) {
-      res
-        .status(201)
-        .send(`Successfully created a new wine with id ${result.insertedId}`)
-    } else {
-      res.status(500).send('Failed to create a new wine.')
-    }
+    handleReqSuccess(res, result, 'wine')
   } catch (error: unknown) {
-    console.error(error)
-
-    if (error instanceof Error) {
-      res.status(400).send(error.message)
-    }
+    handleReqError(res, error)
   }
 })
diff --git a/src/types/product.ts b/src/types/product.ts
index 653a622..d562f86 100644
--- a/src/types/product.ts
+++ b/src/types/product.ts
@@ -61,4 +61,13 @@ export type Ingredient =
   | 'Pecan'
   | 'Walnut'
 
-export type Vintage = number | 'nv' | 'mv'
+export enum VintageOptions {
+  NV = 'nv',
+  MV = 'mv'
+}
+
+export interface StandardDrinks100ml {
+  AU: number
+  UK: number
+  US: number
+}
diff --git a/src/types/wine.ts b/src/types/wine.ts
index fabc693..c26bccd 100644
--- a/src/types/wine.ts
+++ b/src/types/wine.ts
@@ -1,5 +1,18 @@
-export type WineType = 'white' | 'amber' | 'rose' | 'red'
+export enum WineType {
+  White = 'white',
+  Amber = 'amber',
+  Rose = 'rose',
+  Red = 'red'
+}
 
-export type Viticulture = 'biodynamic' | 'organic' | 'conventional'
+export enum Viticulture {
+  Biodynamic = 'biodynamic',
+  Organic = 'organic',
+  Conventional = 'conventional'
+}
 
-export type BottleClosure = 'cork' | 'crown-seal' | 'screwcap'
+export enum BottleClosure {
+  Cork = 'cork',
+  CrownSeal = 'crown-seal',
+  Screwcap = 'screwcap'
+}
diff --git a/src/utils/alcohol.ts b/src/utils/alcohol.ts
new file mode 100644
index 0000000..3cf3f3a
--- /dev/null
+++ b/src/utils/alcohol.ts
@@ -0,0 +1,10 @@
+import { StandardDrinks100ml } from '../types'
+import { roundToOneDecimal } from './'
+
+export const alcoholToStandardDrinks = (
+  alcohol: number
+): StandardDrinks100ml => ({
+  UK: roundToOneDecimal(10 * alcohol),
+  AU: roundToOneDecimal(7.91 * alcohol),
+  US: roundToOneDecimal(5.64 * alcohol)
+})
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 086db4e..8455920 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -1,3 +1,5 @@
 export * from './validation'
 export * from './nostr'
 export * from './route'
+export * from './utils'
+export * from './alcohol'
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
new file mode 100644
index 0000000..5019bf3
--- /dev/null
+++ b/src/utils/utils.ts
@@ -0,0 +1,2 @@
+export const roundToOneDecimal = (number: number) =>
+  Math.round(number * 10) / 10
diff --git a/src/utils/validation/index.ts b/src/utils/validation/index.ts
index baa0c5a..33668f0 100644
--- a/src/utils/validation/index.ts
+++ b/src/utils/validation/index.ts
@@ -1,3 +1,4 @@
 export * from './user'
 export * from './nostr'
 export * from './review'
+export * from './wine'
diff --git a/src/utils/validation/wine.ts b/src/utils/validation/wine.ts
new file mode 100644
index 0000000..fa90e9e
--- /dev/null
+++ b/src/utils/validation/wine.ts
@@ -0,0 +1,43 @@
+import Joi from 'joi'
+import {
+  WineType,
+  VintageOptions,
+  BottleClosure,
+  Viticulture
+} from '../../types'
+
+export const wineValidation = (data: unknown): Joi.ValidationResult =>
+  Joi.object({
+    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.string().required(),
+    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(),
+    alcohol: Joi.number().min(0).max(0.99).required(),
+    viticulture: Joi.string()
+      .valid(...Object.values(Viticulture))
+      .required(),
+    sulfites: Joi.number().required(),
+    filtered: Joi.boolean().required(),
+    vegan: Joi.boolean().required(),
+    kosher: Joi.boolean().required(),
+    closure: Joi.string()
+      .valid(...Object.values(BottleClosure))
+      .required(),
+    RRPamount: Joi.number().required(),
+    RRPcurrency: Joi.string().length(3).required(),
+    description: Joi.string().required(),
+    url: Joi.string(),
+    image: Joi.string()
+  }).validate(data)
-- 
2.43.0