diff --git a/public/openapi.yaml b/public/openapi.yaml index e9258467ed57f91f554ea1d34df74b422560c590..bbe630767b8a9eed22fb503b2439f3338b5785d3 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.1 +openapi: 3.1.0 info: title: Lager's OpenAPI description: warehouse management system @@ -71,8 +71,18 @@ paths: application/json: schema: $ref: '#/components/schemas/Product' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' 500: - description: something went wrong (in a better version error should be refined) + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' /products: post: @@ -144,6 +154,55 @@ paths: schema: $ref: '#/components/schemas/ErrorMessage' + /products/{productId}/sell: + post: + tags: + - products + summary: Sell a product + description: Sell a given amount of one product + parameters: + - in: path + name: productId + schema: + type: integer + required: true + description: Numeric ID of the product to get + requestBody: + content: + application/json: + schema: + type: object + required: + - amount + properties: + amount: + type: integer + responses: + 200: + description: The updated product + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + 400: + description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + 404: + description: Parts or Product Not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + 500: + description: Internal error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorMessage' + components: schemas: HellWorld: @@ -213,7 +272,7 @@ components: amount_of: type: integer stock: - type: integer, + type: integer art_name: type: string diff --git a/src/codecs/product.ts b/src/codecs/product.ts index f287e176829aa10c453aee7351e47d5f45a7af53..a3a50e6cdcbfff5296d81e9354738258522b13b1 100644 --- a/src/codecs/product.ts +++ b/src/codecs/product.ts @@ -15,11 +15,16 @@ export const productParamsC = t.type({ productId: tt.IntFromString }) +export const productSellC = t.type({ + amount: t.number +}) + export type Product = { id: number, name: string, availability: number, contain_articles: { + amount_of: number, art_id: number, stock: number, art_name: string diff --git a/src/server/controllers/inventory.ts b/src/server/controllers/inventory.ts index 2111801e413ad0253690f99c3caa72dbaea1ea0c..555e8ca369e675b67e4b1b0467f30a5e3d2eaa47 100644 --- a/src/server/controllers/inventory.ts +++ b/src/server/controllers/inventory.ts @@ -5,6 +5,7 @@ import * as T from 'fp-ts/Task' import { pipe } from 'fp-ts/function' import { inventoryPartRequestC } from '@/codecs/inventory' import { decodeWith } from '@/utils' +import { handleError } from '@/server/utils' export interface InventoryControllers { getAllInventory: Handler, @@ -23,10 +24,8 @@ export default (repo: InventoryRepo): InventoryControllers => ({ decodeWith(inventoryPartRequestC)(req.body), TE.fromEither, TE.map(part => ({ ...part, stock: part.stock || 0 })), - TE.chain(part => repo.create(part)), - TE.fold( - errors => T.of(res.status(500).send(errors)), - partId => T.of(res.status(200).json(partId)) - ) + TE.chainW(part => repo.create(part)), + TE.map(partId => res.status(200).json(partId)), + handleError(res) )() }) diff --git a/src/server/controllers/product.ts b/src/server/controllers/product.ts index 9048a5fd07c5d332fd00c3728cb1483244108cac..18eb36f69f773ca0c9e49567a28e648dc4bebb48 100644 --- a/src/server/controllers/product.ts +++ b/src/server/controllers/product.ts @@ -1,14 +1,16 @@ import { ProductRepo } from '@/storage/productRepo' import { Handler } from 'express' import { pipe } from 'fp-ts/function' -import { productParamsC, productCreateC } from '@/codecs/product' +import { productParamsC, productCreateC, productSellC } from '@/codecs/product' import * as TE from 'fp-ts/TaskEither' +import * as E from 'fp-ts/Either' import { decodeWith } from '@/utils' import { handleError } from '@/server/utils' export interface ProductControllers { getProduct: Handler, - postProduct: Handler + postProduct: Handler, + sellProduct: Handler } export default (repo: ProductRepo): ProductControllers => ({ @@ -25,5 +27,16 @@ export default (repo: ProductRepo): ProductControllers => ({ TE.chainW(repo.create), TE.map(product => res.status(201).json(product)), handleError(res) + )(), + sellProduct: (req, res) => pipe( + decodeWith(productSellC)(req.body), + E.chain((body) => pipe( + decodeWith(productParamsC)(req.params), + E.map((params) => ([params, body] as [{ productId: number }, { amount: number }])) + )), + TE.fromEither, + TE.chainW(([params, body]) => repo.sell(params.productId, body.amount)), + TE.map(product => res.status(200).json(product)), + handleError(res) )() }) diff --git a/src/server/errors.ts b/src/server/errors.ts index 51cc04cfcd5e91b0bf4db8b832e1f3a752a8db98..206172f0237ee4636eef6edcab6812bd24b7f80c 100644 --- a/src/server/errors.ts +++ b/src/server/errors.ts @@ -9,6 +9,14 @@ export class DecodeError extends Error { } } +export class DBDecodeError extends Error { + constructor ( + readonly errors: Errors + ) { + super() + } +} + export class NotFound extends Error { constructor ( readonly entityType: string, @@ -27,12 +35,14 @@ export class ApiError extends Error { } } -export type HandlerErrors = NotFound | DecodeError | DatabaseError +export type HandlerErrors = NotFound | DecodeError | DatabaseError | DBDecodeError const refineDbError = (dbError: DatabaseError) => { switch (dbError.constraint) { case 'product_to_inventory_amount_of_check': return new ApiError(400, 'amount of a piece can\'t be 0') + case 'inventory_stock_check': + return new ApiError(400, 'the stock can\'t be below 0') case 'inventory_unique_article_id': return new ApiError(400, 'that part already exist') case 'pti_product_id_exist': @@ -46,6 +56,8 @@ const refineDbError = (dbError: DatabaseError) => { export const toApiError = (error: Error): ApiError => { switch (true) { + case error.constructor === DBDecodeError.prototype.constructor: + return new ApiError(500, 'wrong database format') case error.constructor === DecodeError.prototype.constructor: return new ApiError(400, 'wrong body format') case error.constructor === NotFound.prototype.constructor: diff --git a/src/server/index.ts b/src/server/index.ts index a5b57cbc9cd0a0c8aa2c7d27e6a97645de6116da..480769539e718d1289ddb4997755a197ea548940 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -10,23 +10,25 @@ export type AppDeps = { dbClient: Client } -export default (deps: AppDeps): Application => express() - .use(expressWinston.logger({ - transports: [ - new winston.transports.Console() - ], - format: winston.format.combine( - winston.format.colorize(), - winston.format.json() - ), - meta: true, - msg: 'HTTP {{req.method}} {{req.url}}', - expressFormat: true, - colorize: false - })) - .use(express.json()) - .use(express.static('public')) - .use('/', makeRoutes({ - inventoryRepo: makeInventoryRepo(deps.dbClient), - productRepo: makeProductRepo(deps.dbClient) - })) +export default (deps: AppDeps): Application => { + const inventoryRepo = makeInventoryRepo(deps.dbClient) + const productRepo = makeProductRepo(deps.dbClient, inventoryRepo) + + return express() + .use(expressWinston.logger({ + transports: [ + new winston.transports.Console() + ], + format: winston.format.combine( + winston.format.colorize(), + winston.format.json() + ), + meta: true, + msg: 'HTTP {{req.method}} {{req.url}}', + expressFormat: true, + colorize: false + })) + .use(express.json()) + .use(express.static('public')) + .use('/', makeRoutes({ inventoryRepo, productRepo })) +} diff --git a/src/server/routes/product.ts b/src/server/routes/product.ts index e2949066050c4f7b1c9f430e5c612e4c1927488a..d8c3768f69d273f65a5e529c2b14d324be8cb959 100644 --- a/src/server/routes/product.ts +++ b/src/server/routes/product.ts @@ -6,5 +6,6 @@ export default (repo: ProductRepo): Router => { const controllers = makeControllers(repo) return Router() .get('/:productId(\\d+)', controllers.getProduct) + .post('/:productId(\\d+)/sell', controllers.sellProduct) .post('/', controllers.postProduct) } diff --git a/src/storage/inventoryRepo.ts b/src/storage/inventoryRepo.ts index e775362b18ab745c04b577e886d83feb1fd5b4d6..acbf87a8ec7a7119f1e8dfdd0ef94e799beb925e 100644 --- a/src/storage/inventoryRepo.ts +++ b/src/storage/inventoryRepo.ts @@ -1,29 +1,56 @@ -import { Client, QueryResult } from 'pg' +import { Client, DatabaseError } from 'pg' import * as TE from 'fp-ts/TaskEither' -import * as E from 'fp-ts/Either' -import { InventoryPart, inventoryPartsC } from '@/codecs/inventory' +import { InventoryPart, inventoryPartC, inventoryPartsC } from '@/codecs/inventory' import { pipe } from 'fp-ts/function' -import { toError } from 'fp-ts/Either' +import { decodeWithDb, tryCatchDb } from '@/utils' +import { DBDecodeError, NotFound } from '@/server/errors' + +export const sql = { + selectAll: 'SELECT * FROM inventory', + selectArt: 'SELECT * FROM inventory WHERE art_id = $1', + insertIntoInventory: 'INSERT INTO inventory(art_id, name, stock) VALUES($1, $2, $3)', + updateInventory: 'UPDATE inventory SET stock = $1 WHERE art_id = $2' +} export interface InventoryRepo { - getAll(): TE.TaskEither<Error, InventoryPart[]>, + getAll(): TE.TaskEither<DatabaseError | DBDecodeError, InventoryPart[]>, + + get(partId: number): TE.TaskEither<DatabaseError | DBDecodeError | NotFound, InventoryPart> + + update(partId: number, newStock: number): TE.TaskEither<DatabaseError | DBDecodeError | NotFound, InventoryPart> - create(part: InventoryPart): TE.TaskEither<Error, number> + create(part: InventoryPart): TE.TaskEither<DatabaseError, number>, } +const getAll = (dbClient: Client) => () => pipe( + tryCatchDb(() => dbClient.query(sql.selectAll)), + TE.chainEitherKW((res) => decodeWithDb(inventoryPartsC)(res.rows)) +) + +const get = (dbClient: Client) => (partId) => pipe( + tryCatchDb(() => dbClient.query(sql.selectArt, [partId])), + TE.chainW(result => result.rowCount === 0 + ? TE.left(new NotFound('article', partId)) + : TE.right(result.rows[0])), + TE.chainEitherKW(decodeWithDb(inventoryPartC)) +) + +const update = (dbClient: Client) => (partId, newStock) => pipe( + tryCatchDb(() => dbClient.query(sql.updateInventory, [newStock, partId])), + TE.chain(() => get(dbClient)(partId)) +) + +const create = (dbClient: Client) => (part) => pipe( + tryCatchDb(() => dbClient.query( + sql.insertIntoInventory, + [part.art_id, part.name, part.stock] + )), + TE.map(() => part.art_id) +) + export default (dbClient: Client): InventoryRepo => ({ - getAll: () => pipe( - TE.tryCatch(() => dbClient.query<unknown>('SELECT * FROM inventory'), toError), - TE.chainEitherK((res: QueryResult<unknown>) => pipe( - inventoryPartsC.decode(res.rows), - E.mapLeft(errors => new Error(errors.join(','))) - )) - ), - create: (part) => pipe( - TE.tryCatch(() => dbClient.query( - 'INSERT INTO inventory(art_id, name, stock) VALUES($1, $2, $3)', - [part.art_id, part.name, part.stock] - ), toError), - TE.map(() => part.art_id) - ) + getAll: getAll(dbClient), + get: get(dbClient), + update: update(dbClient), + create: create(dbClient) }) diff --git a/src/storage/productRepo.ts b/src/storage/productRepo.ts index fd76d53df4ad9e00a65ee7fd5c52905329a3a572..cbaaf6117e44664dcb5c92711cfd680626a122a4 100644 --- a/src/storage/productRepo.ts +++ b/src/storage/productRepo.ts @@ -4,7 +4,8 @@ import * as TE from 'fp-ts/TaskEither' import { Product, ProductCreate } from '@/codecs/product' import { pipe } from 'fp-ts/function' import { tryCatchDb } from '@/utils' -import { NotFound } from '@/server/errors' +import { DBDecodeError, NotFound } from '@/server/errors' +import { InventoryRepo } from '@/storage/inventoryRepo' export const sql = { getProduct: 'SELECT product_id, p.name, amount_of, i.art_id, i.stock as art_stock, i.name as art_name, div(i.stock, amount_of) AS availability' + @@ -21,6 +22,8 @@ export interface ProductRepo { get(productId: number): TE.TaskEither<DatabaseError | NotFound, Product> create(product: ProductCreate): TE.TaskEither<DatabaseError | NotFound, Product> + + sell(productId: number, amount: number): TE.TaskEither<DatabaseError | NotFound | DBDecodeError, Product> } const getProduct = (dbClient: Client) => (productId) => pipe( @@ -66,7 +69,22 @@ const createProduct = (dbClient: Client) => (product) => pipe( TE.chainFirst(() => tryCatchDb(() => dbClient.query('COMMIT'))) ) -export default (dbClient: Client): ProductRepo => ({ +const sell = (dbClient: Client, inventoryRepo: InventoryRepo) => (productId, amount) => pipe( + tryCatchDb(() => dbClient.query('BEGIN')), + TE.chain(() => getProduct(dbClient)(productId)), + TE.chain((product: Product) => TE.sequenceArray( + product + .contain_articles + .map(article => + inventoryRepo.update(article.art_id, article.stock - article.amount_of * amount)) + )), + TE.chain(() => getProduct(dbClient)(productId)), + TE.orElseFirstW(() => tryCatchDb(() => dbClient.query('ROLLBACK'))), + TE.chainFirst(() => tryCatchDb(() => dbClient.query('COMMIT'))) +) + +export default (dbClient: Client, inventoryRepo: InventoryRepo): ProductRepo => ({ get: getProduct(dbClient), - create: createProduct(dbClient) + create: createProduct(dbClient), + sell: sell(dbClient, inventoryRepo) }) diff --git a/src/utils.ts b/src/utils.ts index 66398de8f89afb93d1468e67d81e02343008825d..097e48315f25eb69e8cc860f3d593342c77698fa 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import { Decoder } from 'io-ts' import * as E from 'fp-ts/Either' -import { DecodeError } from '@/server/errors' +import { DBDecodeError, DecodeError } from '@/server/errors' import { Lazy, pipe } from 'fp-ts/function' import * as TE from 'fp-ts/TaskEither' import { DatabaseError } from 'pg' @@ -11,5 +11,11 @@ export const decodeWith = <A, B>(decoder: Decoder<A, B>) => (val: A): E.Either<D E.mapLeft(errors => new DecodeError(errors)) ) +export const decodeWithDb = <A, B>(decoder: Decoder<A, B>) => (val: A): E.Either<DBDecodeError, B> => + pipe( + decoder.decode(val), + E.mapLeft(errors => new DBDecodeError(errors)) + ) + export const tryCatchDb = <A>(fa: Lazy<Promise<A>>): TE.TaskEither<DatabaseError, A> => TE.tryCatch(fa, err => err as DatabaseError) diff --git a/tests/server/product.spec.ts b/tests/server/product.spec.ts index 78c003c5e1c8b4ba48eb210cd8b571a8ad99e3b3..1cb43a224501b28b4a5bdc7c2df15d6849353444 100644 --- a/tests/server/product.spec.ts +++ b/tests/server/product.spec.ts @@ -6,6 +6,7 @@ import request from 'supertest' import PgMock2 from 'pgmock2' import { sql } from '@/storage/productRepo' +import { sql as inventorySql } from '@/storage/inventoryRepo' import { Client } from 'pg' import makeServer from '@/server' import { expectedProductApiAnswer, getProductSqlAnswer, product } from '@tests/fixtures/products' @@ -102,5 +103,42 @@ describe('product routes', () => { expect(response.body).to.eql(expectedProductApiAnswer(productId))) }) }) + + describe('POST sell', () => { + it('should return the updated product on sell', async () => { + const pg = new PgMock2() + const productId = 1 + + pg.add('BEGIN', [], {}) + pg.add('COMMIT', [], {}) + pg.add('ROLLBACK', [], {}) + + pg.add(inventorySql.updateInventory, ['number', 'number'], { + rowCount: 1 + }) + + pg.add(inventorySql.selectArt, ['number'], { + rowCount: 1, + rows: [{ + stock: 20, + name: 'an article', + art_id: 1 + }] + }) + + pg.add(sql.getProduct, ['number'], { + rowCount: 4, + rows: getProductSqlAnswer(productId) + }) + + const server = await serveWithPG(pg) + return request(server) + .post(`/products/${productId}/sell`) + .send({ amount: 2 }) + .expect(200) + .expect((response) => + expect(response.body).to.eql(expectedProductApiAnswer(productId))) + }) + }) }) })