Магазин на JavaScript, часть 8 из 19. Работа со свойствами товара и корзиной покупателя

04.12.2021

Теги: BackendExpress.jsFrontendJavaScriptNode.jsORMReact.jsWeb-разработкаБазаДанныхИнтернетМагазинКаталогТоваровКорзинаФреймворк

Свойства товара

Сейчас работать со свойствами товара не слишком удобно. Если нам надо добавить новое свойство, отредактировать или удалить существующее, надо обновить товар целиком. Тогда все старые свойства товара будут удалены, а новые добавлены. Давайте это исправим, чтобы со свойствами товаров можно было работать точечно, с каждым по отдельности.

Добавляем новые маршруты в файл routes/product.js:

import express from 'express'
import ProductController from '../controllers/Product.js'
import ProductPropController from '../controllers/ProductProp.js'
import authMiddleware from '../middleware/authMiddleware.js'
import adminMiddleware from '../middleware/adminMiddleware.js'

const router = new express.Router()

/*
 * Товары
 */

// список товаров выбранной категории и выбранного бренда
router.get('/getall/categoryId/:categoryId([0-9]+)/brandId/:brandId([0-9]+)', ProductController.getAll)
// список товаров выбранной категории
router.get('/getall/categoryId/:categoryId([0-9]+)', ProductController.getAll)
// список товаров выбранного бренда
router.get('/getall/brandId/:brandId([0-9]+)', ProductController.getAll)
// список всех товаров каталога
router.get('/getall', ProductController.getAll)
// получить один товар каталога
router.get('/getone/:id([0-9]+)', ProductController.getOne)
// создать товар каталога — нужны права администратора
router.post('/create', authMiddleware, adminMiddleware, ProductController.create)
// обновить товар каталога  — нужны права администратора
router.put('/update/:id([0-9]+)', authMiddleware, adminMiddleware, ProductController.update)
// удалить товар каталога  — нужны права администратора
router.delete('/delete/:id([0-9]+)', authMiddleware, adminMiddleware, ProductController.delete)

/*
 * Свойства
 */

// список свойств товара
router.get('/product/:productId([0-9]+)/property/getall', ProductPropController.getAll)
// одно свойство товара
router.get('/product/:productId([0-9]+)/property/getone/:id([0-9]+)', ProductPropController.getOne)
// создать свойство товара
router.post(
    '/product/:productId([0-9]+)/property/create',
    authMiddleware,
    adminMiddleware,
    ProductPropController.create
)
// обновить свойство товара
router.put(
    '/product/:productId([0-9]+)/property/update/:id([0-9]+)',
    authMiddleware,
    adminMiddleware,
    ProductPropController.update
)
// удалить свойство товара
router.delete(
    '/product/:productId([0-9]+)/property/delete/:id([0-9]+)',
    authMiddleware,
    adminMiddleware,
    ProductPropController.delete
)

export default router

Создадим новую модель models/ProductProp.js:

import { ProductProp as ProductPropMapping } from './mapping.js'
import { Product as ProductMapping } from './mapping.js'
import AppError from '../errors/AppError.js'

class ProductProp {
    async getAll(productId) {
        const product = await ProductMapping.findByPk(productId)
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        const properties = await ProductPropMapping.findAll({where: {productId}})
        return properties
    }

    async getOne(productId, id) {
        const product = await ProductMapping.findByPk(productId)
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        const property = await ProductPropMapping.findOne({where: {productId, id}})
        if (!property) {
            throw new Error('Свойство товара не найдено в БД')
        }
        return property
    }

    async create(productId, data) {
        const product = await ProductMapping.findByPk(productId)
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        const {name, value} = data
        const property = await ProductPropMapping.create({name, value, productId})
        return property
    }

    async update(productId, id, data) {
        const product = await ProductMapping.findByPk(productId)
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        const property = await ProductPropMapping.findOne({where: {productId, id}})
        if (!property) {
            throw new Error('Свойство товара не найдено в БД')
        }
        const {name = property.name, value = property.value} = data
        await property.update({name, value})
        return property
    }

    async delete(productId, id) {
        const product = await ProductMapping.findByPk(productId)
        if (!product) {
            throw new Error('Товар не найден в БД')
        }
        const property = await ProductPropMapping.findOne({where: {productId, id}})
        if (!property) {
            throw new Error('Свойство товара не найдено в БД')
        }
        await property.destroy()
        return property
    }
}

export default new ProductProp()

Создадим новый контроллер controllers/ProductProp.js:

import ProductPropModel from '../models/ProductProp.js'
import AppError from '../errors/AppError.js'

class ProductProp {
    async getAll(req, res, next) {
        try {
            if (!req.params.productId) {
                throw new Error('Не указан id товара')
            }
            const properties = await ProductPropModel.getAll(req.params.productId)
            res.json(properties)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async getOne(req, res, next) {
        try {
            if (!req.params.productId) {
                throw new Error('Не указан id товара')
            }
            if (!req.params.id) {
                throw new Error('Не указано id свойства')
            }
            const property = await ProductPropModel.getOne(req.params.productId, req.params.id)
            res.json(property)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async create(req, res, next) {
        try {
            if (!req.params.productId) {
                throw new Error('Не указан id товара')
            }
            if (Object.keys(req.body).length === 0) {
                throw new Error('Нет данных для создания')
            }
            const property = await ProductPropModel.create(req.params.productId, req.body)
            res.json(property)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async update(req, res, next) {
        try {
            if (!req.params.productId) {
                throw new Error('Не указан id товара')
            }
            if (!req.params.id) {
                throw new Error('Не указано id свойства')
            }
            if (Object.keys(req.body).length === 0) {
                throw new Error('Нет данных для обновления')
            }
            const property = await ProductPropModel.update(req.params.productId, req.params.id, req.body)
            res.json(property)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async delete(req, res, next) {
        try {
            if (!req.params.productId) {
                throw new Error('Не указан id товара')
            }
            if (!req.params.id) {
                throw new Error('Не указано id свойства')
            }
            const property = await ProductPropModel.delete(req.params.productId, req.params.id)
            res.json(property)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }
}

export default new ProductProp()

Для тестирования запросов создадим файл test/product_prop.http. Чтобы упростить тестирование и не отправлять с запросами JWT-токен, закомментируем в роутере middleware authMiddleware и adminMiddleware.

// создать свойство товара
router.post(
    '/:productId([0-9]+)/property/create',
    // authMiddleware,
    // adminMiddleware,
    ProductPropController.create
)
// обновить свойство товара
router.put(
    '/:productId([0-9]+)/property/update/:id([0-9]+)',
    // authMiddleware,
    // adminMiddleware,
    ProductPropController.update
)
// удалить свойство товара
router.delete(
    '/:productId([0-9]+)/property/delete/:id([0-9]+)',
    // authMiddleware,
    // adminMiddleware,
    ProductPropController.delete
)
### Список всех свойств товара id=1
GET /api/product/1/property/getall HTTP/1.1
Host: localhost:7000

### Получить одно свойство товара
GET /api/product/1/property/getone/1 HTTP/1.1
Host: localhost:7000

### Создать новое свойство товара
POST /api/product/1/property/create HTTP/1.1
Host: localhost:7000
Content-type: application/json; charset=utf-8

{
    "name": "Новое свойство товара",
    "value": "Значение свойства товара"
}

### Обновить свойство товара
PUT /api/product/1/property/update/3 HTTP/1.1
Host: localhost:7000
Content-type: application/json; charset=utf-8

{
    "name": "Свойство товара (updated)",
    "value": "Значение свойства (updated)"
}

### Удалить свойство товара
DELETE /api/product/1/property/delete/3 HTTP/1.1
Host: localhost:7000

Корзина покупателя

Нужна возможность создания, обновления и очистки корзины. После оформления заказа корзина очищается, но не удаляется — чтобы использовать в следующий раз вместо создания новой. При работе с корзиной на сервере будем отправлять клиенту заголовок Set-Cookie с идентификатором корзины.

> npm install cookie-parser

Добавляем middleware для работы с cookie в server/index.js:

import cookieParser from 'cookie-parser'
/* .......... */
app.use(cookieParser(process.env.SECRET_KEY))

Создаем файл routes/basket.js, где описываем новые маршруты:

import express from 'express'
import BasketController from '../controllers/Basket.js'

const router = new express.Router()

router.get('/getone', BasketController.getOne)
router.put('/product/:productId([0-9]+)/append/:quantity([0-9]+)', BasketController.append)
router.put('/product/:productId([0-9]+)/increment/:quantity([0-9]+)', BasketController.increment)
router.put('/product/:productId([0-9]+)/decrement/:quantity([0-9]+)', BasketController.decrement)
router.put('/product/:productId([0-9]+)/remove', BasketController.remove)
router.put('/clear', BasketController.clear)

export default router

Редактируем routes/index.js, чтобы новые маршруты стали доступны:

import express from 'express'

import product from './product.js'
import category from './category.js'
import brand from './brand.js'
import user from './user.js'
import basket from './basket.js'

const router = new express.Router()

router.use('/product', product)
router.use('/category', category)
router.use('/brand', brand)
router.use('/user', user)
router.use('/basket', basket)

export default router

Создаем контроллер controllers/Basket.js:

import BasketModel from '../models/Basket.js'
import ProductModel from '../models/Product.js'
import AppError from '../errors/AppError.js'

const maxAge = 60 * 60 * 1000 * 24 * 365 // один год
const signed = true

class Basket {
    async getOne(req, res, next) {
        try {
            let basket
            if (req.signedCookies.basketId) {
                basket = await BasketModel.getOne(parseInt(req.signedCookies.basketId))
            } else {
                basket = await BasketModel.create()
            }
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async append(req, res, next) {
        try {
            let basketId
            if (!req.signedCookies.basketId) {
                let created = await BasketModel.create()
                basketId = created.id
            } else {
                basketId = parseInt(req.signedCookies.basketId)
            }
            const {productId, quantity} = req.params
            const basket = await BasketModel.append(basketId, productId, quantity)
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async increment(req, res, next) {
        try {
            let basketId
            if (!req.signedCookies.basketId) {
                let created = await BasketModel.create()
                basketId = created.id
            } else {
                basketId = parseInt(req.signedCookies.basketId)
            }
            const {productId, quantity} = req.params
            const basket = await BasketModel.increment(basketId, productId, quantity)
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async decrement(req, res, next) {
        try {
            let basketId
            if (!req.signedCookies.basketId) {
                let created = await BasketModel.create()
                basketId = created.id
            } else {
                basketId = parseInt(req.signedCookies.basketId)
            }
            const {productId, quantity} = req.params
            const basket = await BasketModel.decrement(basketId, productId, quantity)
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async remove(req, res, next) {
        try {
            let basketId
            if (!req.signedCookies.basketId) {
                let created = await BasketModel.create()
                basketId = created.id
            } else {
                basketId = parseInt(req.signedCookies.basketId)
            }
            const basket = await BasketModel.remove(basketId, req.params.productId)
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }

    async clear(req, res, next) {
        try {
            let basketId
            if (!req.signedCookies.basketId) {
                let created = await BasketModel.create()
                basketId = created.id
            } else {
                basketId = parseInt(req.signedCookies.basketId)
            }
            basket = await BasketModel.clear(basketId)
            res.cookie('basketId', basket.id, {maxAge, signed})
            res.json(basket)
        } catch(e) {
            next(AppError.badRequest(e.message))
        }
    }
}

export default new Basket()

Создаем модель models/Basket.js:

import { Basket as BasketMapping } from './mapping.js'
import { Product as ProductMapping } from './mapping.js'
import { BasketProduct as BasketProductMapping } from './mapping.js'
import AppError from '../errors/AppError.js'

class Basket {
    async getOne(basketId) {
        let basket = await BasketMapping.findByPk(basketId, {
            attributes: ['id'],
            include: [
                {model: ProductMapping, attributes: ['id', 'name', 'price']},
            ],
        })
        if (!basket) {
            basket = await BasketMapping.create()
        }
        return basket
    }

    async create() {
        const basket = await BasketMapping.create()
        return basket
    }

    async append(basketId, productId, quantity) {
        let basket = await BasketMapping.findByPk(basketId, {
            attributes: ['id'],
            include: [
                {model: ProductMapping, attributes: ['id', 'name', 'price']},
            ]
        })
        if (!basket) {
            basket = await BasketMapping.create()
        }
        // проверяем, есть ли уже этот товар в корзине
        const basket_product = await BasketProductMapping.findOne({
            where: {basketId, productId}
        })
        if (basket_product) { // есть в корзине
            await basket_product.increment('quantity', {by: quantity})
        } else { // нет в корзине
            await BasketProductMapping.create({basketId, productId, quantity})
        }
        // обновим объект корзины, чтобы вернуть свежие данные
        await basket.reload()
        return basket
    }

    async increment(basketId, productId, quantity) {
        let basket = await BasketMapping.findByPk(basketId, {
            include: [{model: ProductMapping, as: 'products'}]
        })
        if (!basket) {
            basket = await BasketMapping.create()
        }
        // проверяем, есть ли этот товар в корзине
        const basket_product = await BasketProductMapping.findOne({
            where: {basketId, productId}
        })
        if (basket_product) {
            await basket_product.increment('quantity', {by: quantity})
            // обновим объект корзины, чтобы вернуть свежие данные
            await basket.reload()
        }
        return basket
    }

    async decrement(basketId, productId, quantity) {
        let basket = await BasketMapping.findByPk(basketId, {
            include: [{model: ProductMapping, as: 'products'}]
        })
        if (!basket) {
            basket = await Basket.create()
        }
        // проверяем, есть ли этот товар в корзине
        const basket_product = await BasketProductMapping.findOne({
            where: {basketId, productId}
        })
        if (basket_product) {
            if (basket_product.quantity > quantity) {
                await basket_product.decrement('quantity', {by: quantity})
            } else {
                await basket_product.destroy()
            }
            // обновим объект корзины, чтобы вернуть свежие данные
            await basket.reload()
        }
        return basket
    }

    async remove(basketId, productId) {
        let basket = await BasketMapping.findByPk(basketId, {
            include: [{model: ProductMapping, as: 'products'}]
        })
        if (!basket) {
            basket = await Basket.create()
        }
        // проверяем, есть ли этот товар в корзине
        const basket_product = await BasketProductMapping.findOne({
            where: {basketId, productId}
        })
        if (basket_product) {
            await basket_product.destroy()
            // обновим объект корзины, чтобы вернуть свежие данные
            await basket.reload()
        }
        return basket
    }

    async clear(basketId) {
        let basket = await BasketMapping.findByPk(basketId, {
            include: [{model: ProductMapping, as: 'products'}]
        })
        if (basket) {
            await BasketProductMapping.destroy({where: {basketId}})
            // обновим объект корзины, чтобы вернуть свежие данные
            await basket.reload()
        } else {
            basket = await Basket.create()
        }
        return basket
    }

    async delete(basketId) {
        const basket = await BasketMapping.findByPk(basketId, {
            include: [{model: ProductMapping, as: 'products'}]
        })
        if (!basket) {
            throw new Error('Корзина не найдена в БД')
        }
        await basket.destroy()
        return basket
    }
}

export default new Basket()

Для тестирования создаем файл test/basket.js:

### Получить корзину
GET /api/basket/getone HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

### Добавить в корзину
PUT /api/basket/product/3/append/5 HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

### Удалить из корзины
PUT /api/basket/product/1/remove HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

### Увеличить количество
PUT /api/basket/product/1/increment/4 HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

### Уменьшить количество
PUT /api/basket/product/1/decrement/2 HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

### Очистить корзину
PUT /api/basket/clear HTTP/1.1
Host: localhost:7000
Cookie: basketId=5

Но перед тестированием изменяем настройку расширения REST Client для VS Code. Расширение запоминает cookie, полученные с сервера и дальше при каждом запросе отправляет их обратно. Нам это не нужно, мы будем сами отправлять cookie с идентификатором корзины на сервер.

{
    "editor.fontSize": 15,
    ..........
    "rest-client.rememberCookiesForSubsequentRequests": false,
}

Post Scriptum

Оказалось, что структура корзины, которую мы возвращаем на клиент, не слишком удобна для работы. Поэтому добавил функцию pretty, чтобы данные корзины имели следующий вид:

{
    "id": 38,
    "products": [
        { "id": 29, "name": "Планшет раз", "price": 78901, "quantity": 1 },
        { "id": 27, "name": "Смартфон раз", "price": 56789, "quantity": 2 }
    ]
}
import { Basket as BasketMapping } from './mapping.js'
import { Product as ProductMapping } from './mapping.js'
import { BasketProduct as BasketProductMapping } from './mapping.js'
import AppError from '../errors/AppError.js'

const pretty = (basket) => {
    const data = {}
    data.id = basket.id
    data.products = []
    if (basket.products) {
        data.products = basket.products.map(item => {
            return {
                id: item.id,
                name: item.name,
                price: item.price,
                quantity: item.basket_product.quantity
            }
        })
    }
    return data
}

class Basket {
        /* .......... */
        return pretty(basket)
    }

    async create() {
        /* .......... */
        return pretty(basket)
    }

    async append(basketId, productId, quantity) {
        /* .......... */
        return pretty(basket)
    }

    async increment(basketId, productId, quantity) {
        /* .......... */
        return pretty(basket)
    }

    async decrement(basketId, productId, quantity) {
        /* .......... */
        return pretty(basket)
    }

    async remove(basketId, productId) {
        /* .......... */
        return pretty(basket)
    }

    async clear(basketId) {
        /* .......... */
        return pretty(basket)
    }

    async delete(basketId) {
        /* .......... */
        return pretty(basket)
    }
}

export default new Basket()

Поиск: Backend • Express.js • Frontend • JavaScript • Node.js • ORM • React.js • Web-разработка • База данных • Интернет магазин • Каталог товаров • Фреймворк • Корзина

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.