From a28b47413a922b5e7d473d8d8268c5c56d587e57 Mon Sep 17 00:00:00 2001 From: Kevin Date: Thu, 29 Dec 2022 19:25:39 +0100 Subject: [PATCH] Improvements to order, product, checkout & added shipping (#2) * planet_id variable casing consistent with database field Renames all clientId variables to planet_id * Store stripe json response from latest payment & charge webhook Also added db seed file for stripe payments * Image support! Can now add, update & delete images from products Images moved away from product schema to it's own table. Accepts a images string and relates it to a product. Does not store the images or verify the existance. * Instead of deleting a product set field unlisted to true Endpoints with current inventory checks for unlisted = false * order_id & customer_no gets enhanced base64 function for generating id * Implemented shipping for orders using Post tracking api Added CRUD for making changes to a order's shippment. Split shipping table into shipment_courier, shipment & shipment_event. * Updated and add product & product_sku functions updated * Cart increment funciton checks stock before updating * Endpoint for getting product audit log using 91pluss trigger Read more about usage here: https://wiki.postgresql.org/wiki/Audit_trigger_91plus * On stripe charge successfull send email to user with planetposen-mail * More product seed data, linting & formatting * Log file at /var/log/planetposen_logs & rotate max 3 files 100MB each This will prob throw a error if folder does not exist, run: `(sudo) mkdir -p /var/log/planetposen_logs`. * All endpoints now prefixed with /api/v1 * Linting --- src/cart/Cart.ts | 53 ++-- src/cart/CartSession.ts | 16 +- src/cart/WSCart.ts | 9 +- src/customer.ts | 22 +- src/database/schemas/0000_functions.sql | 126 +++++++++ src/database/schemas/0004_product.sql | 18 +- src/database/schemas/0005_cart.sql | 13 +- src/database/schemas/0006_customer.sql | 8 +- src/database/schemas/0009_orders.sql | 22 +- src/database/schemas/0011_stripe_payments.sql | 5 +- src/database/schemas/0012_shipping.sql | 31 +++ src/database/scripts/seedDatabase.ts | 20 +- src/database/seeds/0002_products.json | 82 +++++- src/database/seeds/0003_customer.json | 10 +- src/database/seeds/0004_cart.json | 2 +- src/database/seeds/0005_order.json | 77 +++++- src/database/seeds/0006_shipping.json | 52 +++- src/database/seeds/0007_stripe_payments.json | 34 +++ src/database/seeds/0008_image.json | 155 +++++++++++ src/email.ts | 56 ++++ src/errors/posten.ts | 29 ++ src/errors/product.ts | 10 + src/index.d.ts | 2 +- src/interfaces/ICart.ts | 2 +- src/interfaces/IShipping.ts | 24 ++ src/logger.ts | 7 +- src/order.ts | 131 +++++---- src/product.ts | 258 +++++++++++------- src/shipping/index.ts | 90 ++++++ src/shipping/posten.ts | 59 ++++ src/stripe/stripeApi.ts | 10 +- src/stripe/stripeRepository.ts | 22 +- src/utils/generateUUID.ts | 2 +- src/warehouse.ts | 66 ++--- src/webserver/controllerResponses.ts | 50 ++++ src/webserver/controllers/orderController.ts | 3 +- .../controllers/productController.ts | 152 +++++++++-- .../controllers/shipmentController.ts | 242 ++++++++++++++++ .../controllers/stripePaymentController.ts | 33 ++- .../controllers/warehouseController.ts | 36 ++- .../middleware/getOrSetCookieForClient.ts | 18 +- src/webserver/middleware/setupHeaders.ts | 2 +- src/webserver/server.ts | 36 ++- src/webserver/websocketCartServer.ts | 10 +- 44 files changed, 1719 insertions(+), 386 deletions(-) create mode 100644 src/database/schemas/0000_functions.sql create mode 100644 src/database/schemas/0012_shipping.sql create mode 100644 src/database/seeds/0007_stripe_payments.json create mode 100644 src/database/seeds/0008_image.json create mode 100644 src/email.ts create mode 100644 src/errors/posten.ts create mode 100644 src/errors/product.ts create mode 100644 src/interfaces/IShipping.ts create mode 100644 src/shipping/index.ts create mode 100644 src/shipping/posten.ts create mode 100644 src/webserver/controllerResponses.ts create mode 100644 src/webserver/controllers/shipmentController.ts diff --git a/src/cart/Cart.ts b/src/cart/Cart.ts index 9be3738..d4d5a2a 100644 --- a/src/cart/Cart.ts +++ b/src/cart/Cart.ts @@ -1,25 +1,23 @@ import establishedDatabase from "../database"; - import ProductRepository from "../product"; - import type { IProduct } from "../interfaces/IProduct"; import type IDeliveryAddress from "../interfaces/IDeliveryAddress"; const productRepository = new ProductRepository(); class Cart { - clientId: string; + planet_id: string; database: any; - constructor(clientId: string) { - this.clientId = clientId; + constructor(planet_id: string) { + this.planet_id = planet_id; this.database = establishedDatabase; } async get(): Promise> { const query = - "SELECT * FROM cart_detailed WHERE client_id = $1 ORDER BY lineitem_id"; - return this.database.all(query, [this.clientId]); + "SELECT * FROM cart_detailed WHERE planet_id = $1 ORDER BY lineitem_id"; + return this.database.all(query, [this.planet_id]); } async getLineItem(product_sku_no: number) { @@ -30,30 +28,30 @@ class Cart { AND cart_id = ( SELECT cart_id FROM cart - WHERE client_id = $1 + WHERE planet_id = $1 ) `; - return this.database.get(query, [this.clientId, product_sku_no]); + return this.database.get(query, [this.planet_id, product_sku_no]); } async exists() { const query = ` SELECT cart_id FROM cart - WHERE client_id = $1 + WHERE planet_id = $1 `; - const exists = await this.database.get(query, [this.clientId]); + const exists = await this.database.get(query, [this.planet_id]); return exists !== undefined; } create() { const query = ` - INSERT INTO cart (client_id) values ($1) ON CONFLICT DO NOTHING + INSERT INTO cart (planet_id) values ($1) ON CONFLICT DO NOTHING `; - return this.database.update(query, [this.clientId]); + return this.database.update(query, [this.planet_id]); } async add(product_no: number, product_sku_no: number, quantity: number) { @@ -68,7 +66,7 @@ class Cart { ( SELECT cart_id FROM cart - WHERE client_id = $1 + WHERE planet_id = $1 ), $2, $3, @@ -86,13 +84,13 @@ class Cart { AND cart_id = ( SELECT cart_id FROM cart - WHERE client_id = $1 + WHERE planet_id = $1 ) `; } return this.database.update(query, [ - this.clientId, + this.planet_id, product_no, product_sku_no, quantity, @@ -100,7 +98,7 @@ class Cart { } remove(lineitem_id: number) { - // TODO should match w/ cart.client_id + // TODO should match w/ cart.planet_id const query = ` DELETE FROM cart_lineitem WHERE lineitem_id = $1 @@ -110,7 +108,7 @@ class Cart { } decrement(lineitem_id: number) { - // TODO should match w/ cart.client_id + // TODO should match w/ cart.planet_id const query = ` UPDATE cart_lineitem SET quantity = quantity - 1 @@ -121,10 +119,23 @@ class Cart { } async increment(lineitem_id: number) { + const inStockQuery = ` + SELECT product_sku.stock >= cart_lineitem.quantity + 1 as in_stock + FROM product_sku + INNER JOIN cart_lineitem + ON cart_lineitem.product_sku_no = product_sku.sku_id + WHERE cart_lineitem.lineitem_id = $1;`; + const inStockResponse = await this.database.get(inStockQuery, [ + lineitem_id, + ]); + if (!inStockResponse?.in_stock || inStockResponse?.in_stock === false) { + throw Error("Unable to add product, no more left in stock"); + } + // if (!productRepository.hasQuantityOfSkuInStock(lineitem_id)) { // throw new SkuQuantityNotInStockError(""); // } - // TODO should match w/ cart.client_id + // TODO should match w/ cart.planet_id const query = ` UPDATE cart_lineitem SET quantity = quantity + 1 @@ -141,8 +152,8 @@ class Cart { removeItem(item: IProduct) {} destroy() { - const query = `DELETE FROM chart WHERE client_id = $1`; - this.database.update(query, [this.clientId]); + const query = `DELETE FROM cart WHERE planet_id = $1`; + this.database.update(query, [this.planet_id]); } } diff --git a/src/cart/CartSession.ts b/src/cart/CartSession.ts index 5809042..0b937da 100644 --- a/src/cart/CartSession.ts +++ b/src/cart/CartSession.ts @@ -14,15 +14,17 @@ class CartSession { this.carts = []; } - getWSCartByClientId(clientId: string): WSCart { - const match = this.carts.find((cart) => cart.wsCart?.clientId === clientId); + getWSCartByplanet_id(planet_id: string): WSCart { + const match = this.carts.find( + (cart) => cart.wsCart?.planet_id === planet_id + ); if (!match) return null; return match?.wsCart; } add(sessionId: string, cart: WSCart) { console.log( - `adding session ${sessionId} with cart id: ${cart?.cart?.clientId}` + `adding session ${sessionId} with cart id: ${cart?.cart?.planet_id}` ); this.carts.push({ wsCart: cart, sessionId }); } @@ -40,13 +42,13 @@ class CartSession { } async emitChangeToClients(wsCart: WSCart) { - const { clientId } = wsCart; + const { planet_id } = wsCart; const matchingCarts = this.carts.filter( - (cart) => cart.wsCart.clientId === clientId + (cart) => cart.wsCart.planet_id === planet_id ); console.log( - `emit change to all carts with id ${clientId}:`, + `emit change to all carts with id ${planet_id}:`, matchingCarts.length ); @@ -68,7 +70,7 @@ class CartSession { this.carts.forEach((cart: IGlobalCart) => { console.table({ isAlive: cart.wsCart?.isAlive, - clientId: cart.wsCart?.clientId, + planet_id: cart.wsCart?.planet_id, sessionId: cart?.sessionId, hasCart: cart.wsCart?.cart !== null, }); diff --git a/src/cart/WSCart.ts b/src/cart/WSCart.ts index 1af9f81..30d237c 100644 --- a/src/cart/WSCart.ts +++ b/src/cart/WSCart.ts @@ -37,14 +37,14 @@ interface ICartPayload { class WSCart { ws: WebSocket; - clientId: string | null; + planet_id: string | null; cart: Cart; cartSession: CartSession; - constructor(ws, clientId) { + constructor(ws, planet_id) { this.ws = ws; - this.clientId = clientId; - this.cart = new Cart(clientId); + this.planet_id = planet_id; + this.cart = new Cart(planet_id); this.cartSession; } @@ -127,6 +127,7 @@ class WSCart { } catch (error) { // ???? if (error.message) this.message(error?.message, false); + this.cartSession.emitChangeToClients(this); } } diff --git a/src/customer.ts b/src/customer.ts index f28ab98..3ff3a35 100644 --- a/src/customer.ts +++ b/src/customer.ts @@ -11,8 +11,8 @@ class CustomerRepository { newCustomer(customer: ICustomer) { const query = ` - INSERT INTO customer (email, first_name, last_name, street_address, zip_code, city) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO customer (customer_no, email, first_name, last_name, street_address, zip_code, city) + VALUES (NULL, $1, $2, $3, $4, $5, $6) RETURNING customer_no `; @@ -47,21 +47,3 @@ class CustomerRepository { } export default CustomerRepository; - -// ``` -// SELECT products.* -// FROM products -// INNER JOIN orders_lineitem -// ON products.product_no = orders_lineitem.product_no -// WHERE order_id = 'fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5'; - -// SELECT orders.* -// FROM orders -// WHERE order_id = 'fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5'; - -// SELECT customer.* -// FROM customer -// INNER JOIN orders -// ON customer.customer_no = orders.customer_no -// WHERE order_id = 'fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5'; -// ```; diff --git a/src/database/schemas/0000_functions.sql b/src/database/schemas/0000_functions.sql new file mode 100644 index 0000000..0ca0a0d --- /dev/null +++ b/src/database/schemas/0000_functions.sql @@ -0,0 +1,126 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Create a trigger function that takes no arguments. +-- Trigger functions automatically have OLD, NEW records +-- and TG_TABLE_NAME as well as others. +CREATE OR REPLACE FUNCTION unique_order_id() +RETURNS TRIGGER AS $$ + + -- Declare the variables we'll be using. +DECLARE + key TEXT; + qry TEXT; + found TEXT; +BEGIN + + -- generate the first part of a query as a string with safely + -- escaped table name, using || to concat the parts + qry := 'SELECT order_id FROM ' || quote_ident(TG_TABLE_NAME) || ' WHERE order_id='; + + IF NEW.order_id IS NOT NULL THEN + RETURN NEW; + END IF; + + -- This loop will probably only run once per call until we've generated + -- millions of ids. + LOOP + + -- Generate our string bytes and re-encode as a base64 string. + key := encode(gen_random_bytes(14), 'base64'); + + -- Base64 encoding contains 2 URL unsafe characters by default. + -- The URL-safe version has these replacements. + key := replace(key, '/', '_'); -- url safe replacement + key := replace(key, '+', '-'); -- url safe replacement + + -- Concat the generated key (safely quoted) with the generated query + -- and run it. + -- SELECT id FROM "test" WHERE id='blahblah' INTO found + -- Now "found" will be the duplicated id or NULL. + EXECUTE qry || quote_literal(key) INTO found; + + -- Check to see if found is NULL. + -- If we checked to see if found = NULL it would always be FALSE + -- because (NULL = NULL) is always FALSE. + IF found IS NULL THEN + + -- If we didn't find a collision then leave the LOOP. + EXIT; + END IF; + + -- We haven't EXITed yet, so return to the top of the LOOP + -- and try again. + END LOOP; + + -- NEW and OLD are available in TRIGGER PROCEDURES. + -- NEW is the mutated row that will actually be INSERTed. + -- We're replacing id, regardless of what it was before + -- with our key variable. + NEW.order_id = key; + + -- The RECORD returned here is what will actually be INSERTed, + -- or what the next trigger will get if there is one. + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE OR REPLACE FUNCTION unique_customer_no() +RETURNS TRIGGER AS $$ + + -- Declare the variables we'll be using. +DECLARE + key TEXT; + qry TEXT; + found TEXT; +BEGIN + + -- generate the first part of a query as a string with safely + -- escaped table name, using || to concat the parts + qry := 'SELECT customer_no FROM ' || quote_ident(TG_TABLE_NAME) || ' WHERE customer_no='; + + IF NEW.customer_no IS NOT NULL THEN + RETURN NEW; + END IF; + -- This loop will probably only run once per call until we've generated + -- millions of ids. + LOOP + + + -- Generate our string bytes and re-encode as a base64 string. + key := encode(gen_random_bytes(14), 'base64'); + + -- Base64 encoding contains 2 URL unsafe characters by default. + -- The URL-safe version has these replacements. + key := replace(key, '/', '_'); -- url safe replacement + key := replace(key, '+', '-'); -- url safe replacement + + -- Concat the generated key (safely quoted) with the generated query + -- and run it. + -- SELECT id FROM "test" WHERE id='blahblah' INTO found + -- Now "found" will be the duplicated id or NULL. + EXECUTE qry || quote_literal(key) INTO found; + + -- Check to see if found is NULL. + -- If we checked to see if found = NULL it would always be FALSE + -- because (NULL = NULL) is always FALSE. + IF found IS NULL THEN + + -- If we didn't find a collision then leave the LOOP. + EXIT; + END IF; + + -- We haven't EXITed yet, so return to the top of the LOOP + -- and try again. + END LOOP; + + -- NEW and OLD are available in TRIGGER PROCEDURES. + -- NEW is the mutated row that will actually be INSERTed. + -- We're replacing id, regardless of what it was before + -- with our key variable. + NEW.customer_no = key; + + -- The RECORD returned here is what will actually be INSERTed, + -- or what the next trigger will get if there is one. + RETURN NEW; +END; +$$ language 'plpgsql'; \ No newline at end of file diff --git a/src/database/schemas/0004_product.sql b/src/database/schemas/0004_product.sql index 9c7900d..2d6c7f5 100644 --- a/src/database/schemas/0004_product.sql +++ b/src/database/schemas/0004_product.sql @@ -2,13 +2,21 @@ CREATE TABLE IF NOT EXISTS product ( product_no serial PRIMARY KEY, name text, description text, - image text, subtext text, primary_color text, created timestamp DEFAULT CURRENT_TIMESTAMP, updated timestamp DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS image ( + image_id serial PRIMARY KEY, + product_no integer REFERENCES product, + url text, + default_image boolean DEFAULT FALSE, + created timestamp DEFAULT CURRENT_TIMESTAMP, + updated timestamp DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS product_sku ( sku_id serial PRIMARY KEY, product_no integer REFERENCES product, @@ -16,15 +24,19 @@ CREATE TABLE IF NOT EXISTS product_sku ( size text, stock real, default_price boolean DEFAULT FALSE, + unlisted boolean DEFAULT FALSE, created timestamp DEFAULT CURRENT_TIMESTAMP, updated timestamp DEFAULT CURRENT_TIMESTAMP ); CREATE OR REPLACE VIEW product_info AS - SELECT product.product_no, product_sku.sku_id, name, image, description, subtext, primary_color, price, size, stock, default_price + SELECT product.product_no, product_sku.sku_id, name, image.url as image, description, subtext, primary_color, price, size, stock, default_price FROM product INNER JOIN product_sku - ON product.product_no = product_sku.product_no; + ON product.product_no = product_sku.product_no + LEFT JOIN image + ON product.product_no = image.product_no + WHERE default_image = TRUE AND product_sku.unlisted != FALSE; CREATE OR REPLACE VIEW available_products AS SELECT * diff --git a/src/database/schemas/0005_cart.sql b/src/database/schemas/0005_cart.sql index 11b85ed..8b0bcc1 100644 --- a/src/database/schemas/0005_cart.sql +++ b/src/database/schemas/0005_cart.sql @@ -1,6 +1,6 @@ CREATE TABLE IF NOT EXISTS cart ( cart_id serial PRIMARY KEY, - client_id text, + planet_id text, created timestamp DEFAULT CURRENT_TIMESTAMP, updated timestamp DEFAULT CURRENT_TIMESTAMP ); @@ -14,14 +14,19 @@ CREATE TABLE IF NOT EXISTS cart_lineitem ( ); CREATE OR REPLACE VIEW cart_detailed AS - SELECT cart.client_id, cart.cart_id, + SELECT cart.planet_id, cart.cart_id, cart_lineitem.lineitem_id, cart_lineitem.quantity, product_sku.sku_id, product_sku.size, product_sku.price, - product.product_no, product.name, product.description, product.subtext, product.image, product.primary_color + -- product.product_no, product.name, product.description, product.subtext, product.image, product.primary_color + product.product_no, product.name, product.description, product.subtext, product.primary_color, image.url as image FROM cart INNER JOIN cart_lineitem ON cart.cart_id = cart_lineitem.cart_id INNER JOIN product_sku ON cart_lineitem.product_sku_no = product_sku.sku_id INNER JOIN product - ON product.product_no = cart_lineitem.product_no; + ON product.product_no = cart_lineitem.product_no + LEFT JOIN image + ON product.product_no = image.product_no + WHERE image.default_image = TRUE; + diff --git a/src/database/schemas/0006_customer.sql b/src/database/schemas/0006_customer.sql index 8c7a684..374942c 100644 --- a/src/database/schemas/0006_customer.sql +++ b/src/database/schemas/0006_customer.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS customer ( - customer_no varchar(36) PRIMARY KEY DEFAULT gen_random_uuid(), + customer_no varchar(36) PRIMARY KEY DEFAULT unique_customer_no(), email text, first_name text, last_name text, @@ -8,4 +8,8 @@ CREATE TABLE IF NOT EXISTS customer ( city text, created timestamp DEFAULT CURRENT_TIMESTAMP, updated timestamp DEFAULT CURRENT_TIMESTAMP -) \ No newline at end of file +); + +CREATE TRIGGER trigger_customerno_genid +BEFORE INSERT ON customer +FOR EACH ROW EXECUTE PROCEDURE unique_customer_no(); \ No newline at end of file diff --git a/src/database/schemas/0009_orders.sql b/src/database/schemas/0009_orders.sql index 1e8ab37..bd4e886 100644 --- a/src/database/schemas/0009_orders.sql +++ b/src/database/schemas/0009_orders.sql @@ -1,5 +1,5 @@ CREATE TABLE IF NOT EXISTS orders ( - order_id varchar(36) PRIMARY KEY DEFAULT gen_random_uuid(), + order_id varchar(36) PRIMARY KEY DEFAULT unique_order_id(), customer_no varchar(36) REFERENCES customer ON DELETE SET NULL, status text DEFAULT 'INITIATED', created timestamp DEFAULT CURRENT_TIMESTAMP, @@ -17,24 +17,14 @@ CREATE TABLE IF NOT EXISTS orders_lineitem ( updated timestamp DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS shipping ( - shipping_id serial PRIMARY KEY, - order_id varchar(36) REFERENCES orders, - shipping_company text, - tracking_code text, - tracking_link text, - user_notified timestamp, - created timestamp DEFAULT CURRENT_TIMESTAMP, - updated timestamp DEFAULT CURRENT_TIMESTAMP -); +CREATE TRIGGER trigger_orderid_genid +BEFORE INSERT ON orders +FOR EACH ROW EXECUTE PROCEDURE unique_order_id(); CREATE OR REPLACE VIEW orders_detailed AS SELECT orders.order_id as order_id, orders.status as order_status, orders.created as order_created, orders.updated as order_updated, - customer.customer_no, customer.email, customer.first_name, customer.last_name, customer.street_address, customer.zip_code, customer.city, - shipping.shipping_id, shipping.shipping_company, shipping.tracking_code, shipping.tracking_link, shipping.user_notified, shipping.created as shipping_created, shipping.updated as shipping_updated + customer.customer_no, customer.email, customer.first_name, customer.last_name, customer.street_address, customer.zip_code, customer.city FROM orders INNER JOIN customer - ON orders.customer_no = customer.customer_no - JOIN shipping - ON orders.order_id = shipping.order_id; \ No newline at end of file + ON orders.customer_no = customer.customer_no; diff --git a/src/database/schemas/0011_stripe_payments.sql b/src/database/schemas/0011_stripe_payments.sql index 20a4784..087c4a4 100644 --- a/src/database/schemas/0011_stripe_payments.sql +++ b/src/database/schemas/0011_stripe_payments.sql @@ -1,11 +1,14 @@ CREATE TABLE IF NOT EXISTS stripe_payments ( - order_id varchar(127) PRIMARY KEY, + payment_id serial PRIMARY KEY, + order_id varchar(36) REFERENCES orders ON DELETE SET NULL, created timestamp DEFAULT CURRENT_TIMESTAMP, updated timestamp DEFAULT CURRENT_TIMESTAMP, -- transaction_text text, -- merchant_serial_number text, -- payment_payload json, stripe_initiation_response json, + stripe_payment_response json, + stripe_charge_response json, stripe_transaction_id text, stripe_status text DEFAULT 'CREATED', -- stripe_failed_payment_status text, diff --git a/src/database/schemas/0012_shipping.sql b/src/database/schemas/0012_shipping.sql new file mode 100644 index 0000000..ff0e740 --- /dev/null +++ b/src/database/schemas/0012_shipping.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS shipment_courier ( + shipment_courier_id serial PRIMARY KEY, + name text, + website text, + has_api boolean, + created timestamp DEFAULT CURRENT_TIMESTAMP, + updated timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS shipment ( + shipment_id serial PRIMARY KEY, + order_id text REFERENCES orders ON DELETE CASCADE, + courier_id int DEFAULT 0, + tracking_code text, + tracking_link text, + user_notified boolean DEFAULT FALSE, + created timestamp DEFAULT CURRENT_TIMESTAMP, + updated timestamp DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS shipment_event ( + shipment_event_id serial PRIMARY KEY, + shipment_id serial REFERENCES shipment ON DELETE CASCADE, + description text, + status text, + location text, + event_time timestamp, + api_payload json, + created timestamp DEFAULT CURRENT_TIMESTAMP, + updated timestamp DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/database/scripts/seedDatabase.ts b/src/database/scripts/seedDatabase.ts index 316920f..8de0c60 100644 --- a/src/database/scripts/seedDatabase.ts +++ b/src/database/scripts/seedDatabase.ts @@ -91,16 +91,22 @@ const readSeedFiles = () => { const seedFolder = path.join(__base, "database/seeds/"); console.log(`Reading seeds from folder: ${seedFolder}\n`); - return fsPromises - .readdir(seedFolder) - .then((files) => - files.reverse().map((filePath) => { + return fsPromises.readdir(seedFolder).then((files) => { + let lastFileRead: string; + try { + return files.reverse().map((filePath) => { + lastFileRead = filePath; const seedStep = new SeedStep(path.join(seedFolder, filePath)); seedStep.readData(); return seedStep; - }) - ) - .catch(console.log); + }); + } catch (error) { + console.log( + `Unexpected error while reading seed files. File: ${lastFileRead} causing error` + ); + throw error; + } + }); }; async function runAllSteps(seedSteps) { diff --git a/src/database/seeds/0002_products.json b/src/database/seeds/0002_products.json index 2f8f65a..85d1ae1 100644 --- a/src/database/seeds/0002_products.json +++ b/src/database/seeds/0002_products.json @@ -6,7 +6,6 @@ "name": "Floral", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/floral.jpg", "primary_color": "#E6E0DC" } }, @@ -28,7 +27,6 @@ "name": "Forrest", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/forrest.jpg", "primary_color": "#C9B2A9" } }, @@ -70,7 +68,6 @@ "name": "Mush", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/mush.jpg", "primary_color": "#231B1D" } }, @@ -92,7 +89,6 @@ "name": "The Buried Life", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/the-buried-life.jpg", "primary_color": "#C9B2A9" } }, @@ -114,7 +110,6 @@ "name": "Cookie-Man Forrest", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/cookie-man-forrest.jpg", "primary_color": "#E6E0DC" } }, @@ -136,7 +131,6 @@ "name": "Yorkshire Shoreline", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/yorkshire-shoreline.jpg", "primary_color": "#E6E0DC" } }, @@ -158,7 +152,6 @@ "name": "Kneeling in Yemen", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/kneeling-in-yemen.jpg", "primary_color": "#E6E0DC" } }, @@ -180,7 +173,6 @@ "name": "Spectural Forrest", "subtext": "By W.H Auden", "description": "Package include 10 pieces, Choose between A-F Key Value", - "image": "https://storage.googleapis.com/planetposen-images/spectural-forrest.jpg", "primary_color": "#E6E0DC" } }, @@ -194,5 +186,79 @@ "default_price": true, "size": "Set" } + }, + { + "model": "product", + "pk": 9, + "fields": { + "name": "Dino Camping", + "subtext": "Snuggle Flannel Fabric", + "description": " - Width: 43 inches\n - Content: 100% Cotton\n - Care: Machine wash normal cold, no bleach, tumble dry, do not iron\n - Imported", + "primary_color": "#E6E0DC" + } + }, + { + "model": "product_sku", + "pk": 11, + "fields": { + "product_no": 9, + "price": 150.0, + "stock": 14, + "default_price": true, + "size": "Set" + } + }, + { + "model": "product", + "pk": 10, + "fields": { + "name": "White Dinosaur Patches", + "subtext": "", + "description": "The power of cotton is an almighty force for kids attire. Our cotton spandex interlock is a thicker fabric that's durable and naturally comfortable. Plus with breathability and slight stretch, children's tops and dresses are free-moving during play time. Choose from various designs like this dinosaur patch pattern, and you have a loving sewing project for awesome practicality and personality.\n\n - Width: 57 Inches\n - Content: 98% Cotton 2% Spandex\n - Care: Machine wash gentle cold, nonchlorine bleach, line dry, cool iron.\n - Imported", + "primary_color": "#B1E0DC" + } + }, + { + "model": "product_sku", + "pk": 12, + "fields": { + "product_no": 10, + "price": 99.0, + "stock": 14, + "default_price": true, + "size": "Medium" + } + }, + { + "model": "product_sku", + "pk": 13, + "fields": { + "product_no": 10, + "price": 140.0, + "stock": 4, + "default_price": false, + "size": "Large" + } + }, + { + "model": "product", + "pk": 11, + "fields": { + "name": "Oversized Blue And Yellow Swirls", + "subtext": "", + "description": " - 44'' Fabric by the Yard\n - 100% Cottton\n - Fabric Care: Machine Wash Normal, No Bleach, Tumble Dry Low\n - Printed in the USA from Imported Material.", + "primary_color": "#E6E0DC" + } + }, + { + "model": "product_sku", + "pk": 14, + "fields": { + "product_no": 11, + "price": 34.0, + "stock": 8, + "default_price": true, + "size": "Small" + } } ] diff --git a/src/database/seeds/0003_customer.json b/src/database/seeds/0003_customer.json index 40b6d7e..0e4c15e 100644 --- a/src/database/seeds/0003_customer.json +++ b/src/database/seeds/0003_customer.json @@ -3,7 +3,7 @@ "model": "customer", "pk": 1, "fields": { - "customer_no": "7CB9A6B8-A526-4836-BF4E-67E1075F8B83", + "customer_no": "fexQy5Q-UtCgzDxixNw=", "email": "kevin.midboe@gmail.com", "first_name": "kevin", "last_name": "midbøe", @@ -16,7 +16,7 @@ "model": "customer", "pk": 2, "fields": { - "customer_no": "FFF49A98-0A2F-437D-9069-9664ADB2FFFE", + "customer_no": "JEpfvCBrpxa4c1R7S8E=", "email": "Maia.Neteland@gmail.com", "first_name": "Maia", "last_name": "Neteland", @@ -29,7 +29,7 @@ "model": "customer", "pk": 3, "fields": { - "customer_no": "DFC94AB1-9BB6-4ECF-8747-3E12751892AB", + "customer_no": "-M_O4M1mvlu5nIVkeZk=", "email": "Aksel.Engeset@gmail.com", "first_name": "Aksel", "last_name": "Engeset", @@ -42,7 +42,7 @@ "model": "customer", "pk": 4, "fields": { - "customer_no": "E235456D-C884-4828-BB0F-5065056BD57A", + "customer_no": "XNHXUnjFK2Q7bqqbDrU=", "email": "Thomas.Langemyr@gmail.com", "first_name": "Thomas", "last_name": "Langemyr", @@ -55,7 +55,7 @@ "model": "customer", "pk": 5, "fields": { - "customer_no": "3C1C1952-87E3-46A8-8B22-383B2F566E26", + "customer_no": "Z9NB1MGeb_as6vScolY=", "email": "Frida.Nilsen@gmail.com", "first_name": "Frida", "last_name": "Nilsen", diff --git a/src/database/seeds/0004_cart.json b/src/database/seeds/0004_cart.json index a318b51..5ee6cb9 100644 --- a/src/database/seeds/0004_cart.json +++ b/src/database/seeds/0004_cart.json @@ -3,7 +3,7 @@ "model": "cart", "pk": 1, "fields": { - "client_id": "800020696e96800f8904ea" + "planet_id": "800020696e96800f8904ea" } }, { diff --git a/src/database/seeds/0005_order.json b/src/database/seeds/0005_order.json index b8e54a2..297a1aa 100644 --- a/src/database/seeds/0005_order.json +++ b/src/database/seeds/0005_order.json @@ -3,15 +3,15 @@ "model": "orders", "pk": 1, "fields": { - "order_id": "fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5", - "customer_no": "7CB9A6B8-A526-4836-BF4E-67E1075F8B83" + "order_id": "ok4jI9EehsRLDHlS6cU=", + "customer_no": "fexQy5Q-UtCgzDxixNw=" } }, { "model": "orders_lineitem", "pk": 1, "fields": { - "order_id": "fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5", + "order_id": "ok4jI9EehsRLDHlS6cU=", "product_no": 2, "product_sku_no": 2, "price": 30, @@ -22,7 +22,7 @@ "model": "orders_lineitem", "pk": 2, "fields": { - "order_id": "fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5", + "order_id": "ok4jI9EehsRLDHlS6cU=", "product_no": 2, "product_sku_no": 3, "price": 50, @@ -33,31 +33,90 @@ "model": "orders_lineitem", "pk": 3, "fields": { - "order_id": "fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5", + "order_id": "ok4jI9EehsRLDHlS6cU=", "product_no": 6, "product_sku_no": 8, "price": 98, "quantity": 18 } }, - { "model": "orders", "pk": 2, "fields": { - "order_id": "2E9EB68E-4224-46C8-9AA2-3A13A55005BA", - "customer_no": "3C1C1952-87E3-46A8-8B22-383B2F566E26" + "order_id": "30TfxKFRd3BeEIeF94M=", + "customer_no": "Z9NB1MGeb_as6vScolY=" } }, { "model": "orders_lineitem", "pk": 4, "fields": { - "order_id": "2E9EB68E-4224-46C8-9AA2-3A13A55005BA", + "order_id": "30TfxKFRd3BeEIeF94M=", "product_no": 2, "product_sku_no": 2, "price": 30, "quantity": 1 } + }, + { + "model": "orders", + "pk": 3, + "fields": { + "order_id": "ezN6SQ6I5EEtSgCKmxE=", + "customer_no": "-M_O4M1mvlu5nIVkeZk=", + "status": "REFUNDED" + } + }, + { + "model": "orders_lineitem", + "pk": 5, + "fields": { + "order_id": "ezN6SQ6I5EEtSgCKmxE=", + "product_no": 2, + "product_sku_no": 2, + "price": 30, + "quantity": 1 + } + }, + { + "model": "orders", + "pk": 4, + "fields": { + "order_id": "XTMt-bSA_wQnwfam5KM=", + "customer_no": "XNHXUnjFK2Q7bqqbDrU=", + "status": "CONFIRMED" + } + }, + { + "model": "orders_lineitem", + "pk": 6, + "fields": { + "order_id": "XTMt-bSA_wQnwfam5KM=", + "product_no": 7, + "product_sku_no": 9, + "price": 78, + "quantity": 2 + } + }, + { + "model": "orders", + "pk": 5, + "fields": { + "order_id": "0upJLUYPEYaOCeQMxPc=", + "customer_no": "JEpfvCBrpxa4c1R7S8E=", + "status": "CONFIRMED" + } + }, + { + "model": "orders_lineitem", + "pk": 7, + "fields": { + "order_id": "0upJLUYPEYaOCeQMxPc=", + "product_no": 6, + "product_sku_no": 8, + "price": 98, + "quantity": 2 + } } ] diff --git a/src/database/seeds/0006_shipping.json b/src/database/seeds/0006_shipping.json index e0f14db..f3019c1 100644 --- a/src/database/seeds/0006_shipping.json +++ b/src/database/seeds/0006_shipping.json @@ -1,13 +1,55 @@ [ { - "model": "shipping", - "pk": 1, + "model": "shipment_courier", + "pk": 100001, "fields": { - "shipping_id": 1, - "order_id": "fb9a5910-0dcf-4c65-9c25-3fb3eb883ce5", - "shipping_company": "Posten BRING", + "shipment_courier_id": 100001, + "name": "Posten BRING", + "website": "http://posten.no", + "has_api": true + } + }, + { + "model": "shipment_courier", + "pk": 100002, + "fields": { + "shipment_courier_id": 100002, + "name": "HeltHjem", + "website": "http://helthjem.no", + "has_api": false + } + }, + { + "model": "shipment", + "pk": 100001, + "fields": { + "shipment_id": 100001, + "order_id": "ok4jI9EehsRLDHlS6cU=", + "courier_id": 100001, "tracking_code": "CS111111111NO", "tracking_link": "https://sporing.posten.no/sporing/CS111111111NO" } + }, + { + "model": "shipment", + "pk": 100002, + "fields": { + "shipment_id": 100002, + "order_id": "XTMt-bSA_wQnwfam5KM=", + "courier_id": 100001, + "tracking_code": "LS395378164NL", + "tracking_link": "https://sporing.posten.no/sporing/LS395378164NL" + } + }, + { + "model": "shipment", + "pk": 100003, + "fields": { + "shipment_id": 100003, + "order_id": "0upJLUYPEYaOCeQMxPc=", + "courier_id": 100001, + "tracking_code": "LW138879799DE", + "tracking_link": "https://sporing.posten.no/sporing/LW138879799DE" + } } ] diff --git a/src/database/seeds/0007_stripe_payments.json b/src/database/seeds/0007_stripe_payments.json new file mode 100644 index 0000000..1752b2f --- /dev/null +++ b/src/database/seeds/0007_stripe_payments.json @@ -0,0 +1,34 @@ +[ + { + "model": "stripe_payments", + "pk": 1, + "fields": { + "order_id": "ezN6SQ6I5EEtSgCKmxE=", + "stripe_initiation_response": "{}", + "stripe_payment_response": "{}", + "stripe_charge_response": "{}", + "stripe_transaction_id": "pi_3MEEltLueS5Cdr0C09n8AMEW", + "stripe_status": "succeeded", + "amount": "5000", + "amount_received": "5000", + "amount_captured": "5000", + "amount_refunded": "5000" + } + }, + { + "model": "stripe_payments", + "pk": 1, + "fields": { + "order_id": "XTMt-bSA_wQnwfam5KM=", + "stripe_initiation_response": "{}", + "stripe_payment_response": "{}", + "stripe_charge_response": "{}", + "stripe_transaction_id": "pi_3MEEzeLueS5Cdr0C0pBoFzW9", + "stripe_status": "succeeded", + "amount": "15600", + "amount_received": "15600", + "amount_captured": "15600", + "amount_refunded": "0" + } + } +] diff --git a/src/database/seeds/0008_image.json b/src/database/seeds/0008_image.json new file mode 100644 index 0000000..7a8fd9d --- /dev/null +++ b/src/database/seeds/0008_image.json @@ -0,0 +1,155 @@ +[ + { + "model": "image", + "pk": 1, + "fields": { + "product_no": 1, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/floral.jpg" + } + }, + { + "model": "image", + "pk": 2, + "fields": { + "product_no": 2, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/forrest.jpg" + } + }, + { + "model": "image", + "pk": 3, + "fields": { + "product_no": 3, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/mush.jpg" + } + }, + { + "model": "image", + "pk": 4, + "fields": { + "product_no": 4, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/the-buried-life.jpg" + } + }, + { + "model": "image", + "pk": 5, + "fields": { + "product_no": 5, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/cookie-man-forrest.jpg" + } + }, + { + "model": "image", + "pk": 6, + "fields": { + "product_no": 6, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/yorkshire-shoreline.jpg" + } + }, + { + "model": "image", + "pk": 7, + "fields": { + "product_no": 7, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/spectural-forrest.jpg" + } + }, + { + "model": "image", + "pk": 8, + "fields": { + "product_no": 7, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/kneeling-in-yemen.jpg" + } + }, + { + "model": "image", + "pk": 9, + "fields": { + "product_no": 8, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/spectural-forrest.jpg" + } + }, + { + "model": "image", + "pk": 10, + "fields": { + "product_no": 9, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/aef3eb500a6b12b896b7c567b85eded6301d5c4a.jpg" + } + }, + { + "model": "image", + "pk": 11, + "fields": { + "product_no": 9, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/cc7632566fcda3dc6659b74fd57246f743f4050f.jpg" + } + }, + { + "model": "image", + "pk": 12, + "fields": { + "product_no": 10, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/838074447f08f03c4b75ac2030dcd01201c0656c.jpg" + } + }, + { + "model": "image", + "pk": 13, + "fields": { + "product_no": 10, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/76c9ed808f016de3c91fbe28ced51af027fd98b1.jpg" + } + }, + { + "model": "image", + "pk": 14, + "fields": { + "product_no": 10, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/e84afa63c52194029a89fa5b14542480a5528e12.jpg" + } + }, + { + "model": "image", + "pk": 15, + "fields": { + "product_no": 10, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/da851171b63d624e5f50c69c8f5a737e49b6b15b.jpg" + } + }, + { + "model": "image", + "pk": 16, + "fields": { + "product_no": 11, + "default_image": true, + "url": "https://storage.googleapis.com/planetposen-images/2c47ed96b5e061d85f688849b998aa5e76c55c2a.jpg" + } + }, + { + "model": "image", + "pk": 17, + "fields": { + "product_no": 11, + "default_image": false, + "url": "https://storage.googleapis.com/planetposen-images/4329b24795654e2c57af859f39081a3891ba695a.jpg" + } + } +] diff --git a/src/email.ts b/src/email.ts new file mode 100644 index 0000000..92cd0c5 --- /dev/null +++ b/src/email.ts @@ -0,0 +1,56 @@ +import type ICustomer from "./interfaces/ICustomer"; +import type { IProduct } from "./interfaces/IProduct"; +import logger from "./logger"; + +class Email { + baseUrl: string; + + constructor() { + this.baseUrl = "http://localhost:8005/api/v1"; + } + + sendConfirmation(orderId: string, customer: ICustomer, products: IProduct[]) { + const url = this.baseUrl + "/send-confirmation"; + let sum = 75; // shipping + let options = {}; + + try { + products.forEach( + (product: IProduct) => (sum = sum + product.price * product.quantity) + ); + + options = { + method: "POST", + body: JSON.stringify({ + email: customer?.email, + orderId, + customer: { + FirstName: customer.first_name, + LastName: customer.last_name, + StreetAddress: customer.street_address, + ZipCode: String(customer.zip_code), + city: customer.city, + }, + products, + sum, + }), + }; + } catch (error) { + logger.error("Unable to parse send confirmation input data", { + error, + orderId, + }); + // throw error + return; + } + + return fetch(url, options) + .then((resp) => resp.json()) + .catch((error) => { + logger.error("Unexpected error from send confirmation", { error }); + // throw error + }); + } +} + +export default Email; diff --git a/src/errors/posten.ts b/src/errors/posten.ts new file mode 100644 index 0000000..88f40fb --- /dev/null +++ b/src/errors/posten.ts @@ -0,0 +1,29 @@ +export class PostenNotFoundError extends Error { + reason: string; + apiError: object; + statusCode: number; + + constructor(apiError) { + const message = "Tracking code not found at Posten BRING"; + super(message); + + this.statusCode = 404; + this.reason = apiError?.errorMessage; + this.apiError = apiError; + } +} + +export class PostenInvalidQueryError extends Error { + reason: string; + apiError: object; + statusCode: number; + + constructor(apiError) { + const message = "Unable to search for tracking code"; + super(message); + + this.statusCode = apiError?.consignmentSet?.[0]?.error?.code || 500; + this.reason = apiError?.consignmentSet?.[0]?.error?.message; + this.apiError = apiError; + } +} diff --git a/src/errors/product.ts b/src/errors/product.ts new file mode 100644 index 0000000..1d81e9e --- /dev/null +++ b/src/errors/product.ts @@ -0,0 +1,10 @@ +export class ProductNotFoundError extends Error { + statusCode: number; + + constructor() { + const message = "Could not find a product with that id"; + super(message); + + this.statusCode = 404; + } +} diff --git a/src/index.d.ts b/src/index.d.ts index 789e9e3..e4655ac 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -3,7 +3,7 @@ export {}; declare global { namespace Express { export interface Request { - planetId?: string; + planet_id?: string; } } } diff --git a/src/interfaces/ICart.ts b/src/interfaces/ICart.ts index fa9ac43..9c08263 100644 --- a/src/interfaces/ICart.ts +++ b/src/interfaces/ICart.ts @@ -1,5 +1,5 @@ export default interface ICart { - client_id: string; + planet_id: string; cart_id: number; lineitem_id: number; quantity: number; diff --git a/src/interfaces/IShipping.ts b/src/interfaces/IShipping.ts new file mode 100644 index 0000000..b6dbe15 --- /dev/null +++ b/src/interfaces/IShipping.ts @@ -0,0 +1,24 @@ +export interface IShippingCourier { + id: string; + name: string; + website: string; + has_api: boolean; +} + +export interface IShipment { + id: string; + courier_id: string; + tracking_code: string; + tracking_link: string; +} + +export interface IShipmentEvent { + event_id: string; + shipment_id: string; + description: string; + status: string; + location: string; + event_time: Date; + updated: Date; + created: Date; +} diff --git a/src/logger.ts b/src/logger.ts index d767b62..b7e0050 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -25,7 +25,7 @@ const customLevels = { }; const appendPlanetId = winston.format((log) => { - log.planetId = httpContext.get("planetId"); + log.planet_id = httpContext.get("planet_id"); return log; }); @@ -39,7 +39,10 @@ const logger = winston.createLogger({ levels: customLevels.levels, transports: [ new winston.transports.File({ - filename: "./logs/all-logs.log", + filename: "/var/log/planetposen_logs/planetposen-backend.log", + maxsize: 100000000, // 100 MB + tailable: true, + maxFiles: 3, format: winston.format.combine(appendPlanetId(), appName(), ecsFormat()), }), ], diff --git a/src/order.ts b/src/order.ts index cc772c2..6195c3d 100644 --- a/src/order.ts +++ b/src/order.ts @@ -1,9 +1,12 @@ import logger from "./logger"; import establishedDatabase from "./database"; +import ShippingRepository from "./shipping"; import { ORDER_STATUS_CODES } from "./enums/order"; import type { IOrder } from "./interfaces/IOrder"; import type { IProduct } from "./interfaces/IProduct"; +const shippingRepository = new ShippingRepository(); + class MissingOrderError extends Error { statusCode: number; @@ -38,9 +41,8 @@ class OrderRepository { async newOrder(customer_no: string) { const query = ` - INSERT INTO orders (customer_no) VALUES ($1) - RETURNING order_id - `; + INSERT INTO orders (order_id, customer_no) VALUES (NULL, $1) + RETURNING order_id`; logger.info("Creating order with customer", { customer_no }); return await this.database.get(query, [customer_no]); @@ -55,8 +57,7 @@ class OrderRepository { ) { const query = ` INSERT INTO orders_lineitem (order_id, product_no, product_sku_no, price, quantity) - VALUES ($1, $2, $3, $4, $5) - `; + VALUES ($1, $2, $3, $4, $5)`; logger.info("Adding lineitem to order", { order_id, @@ -80,8 +81,7 @@ class OrderRepository { FROM orders WHERE product_no = $1 AND NOT status = '' - AND end_time > now() - `; + AND end_time > now()`; return this.database.all(query, [product.product_no]); } @@ -95,8 +95,7 @@ class OrderRepository { AND NOT order_id = $2 AND NOT status = $3 AND NOT status = $4 - AND NOT status = $5 - `; + AND NOT status = $5`; return { order, @@ -112,95 +111,88 @@ class OrderRepository { getAll() { const query = ` -SELECT orders.created, orders.order_id, customer.first_name, customer.last_name, customer.email, status, orders_lines.order_sum -FROM orders -INNER JOIN ( - SELECT order_id, SUM(quantity * orders_lineitem.price) as order_sum - FROM orders_lineitem - INNER JOIN product_sku - ON orders_lineitem.product_sku_no = product_sku.sku_id - GROUP BY order_id -) AS orders_lines -ON orders.order_id = orders_lines.order_id -INNER JOIN customer -ON customer.customer_no = orders.customer_no;`; + SELECT orders.created, orders.order_id, customer.first_name, customer.last_name, customer.email, status, orders_lines.order_sum + FROM orders + INNER JOIN customer + ON customer.customer_no = orders.customer_no + LEFT JOIN ( + SELECT order_id, SUM(quantity * orders_lineitem.price) as order_sum + FROM orders_lineitem + INNER JOIN product_sku + ON orders_lineitem.product_sku_no = product_sku.sku_id + GROUP BY order_id + ) AS orders_lines + ON orders.order_id = orders_lines.order_id + ORDER BY orders.updated DESC;`; return this.database.all(query, []); - - // SELECT orders.created, orders.order_id, customer.first_name, customer.last_name, customer.email, status, orders_lines.sku_id, orders_lines.quantity, size, price, orders_lines.quantity * price as sum - // FROM orders - // INNER JOIN ( - // SELECT order_id, sku_id, product_sku.product_no, size, orders_lineitem.price, stock, quantity - // FROM orders_lineitem - // INNER JOIN product_sku - // ON orders_lineitem.product_sku_no = product_sku.sku_id - // ) AS orders_lines - // ON orders.order_id = orders_lines.order_id - // INNER JOIN customer - // ON customer.customer_no = orders.customer_no; } async getOrderDetailed(orderId) { const customerQuery = ` -SELECT customer.* -FROM orders -INNER JOIN ( - SELECT customer_no, email, first_name, last_name, street_address, zip_code, city - FROM customer -) AS customer -ON orders.customer_no = customer.customer_no -WHERE order_id = $1`; + SELECT customer.* + FROM orders + INNER JOIN ( + SELECT customer_no, email, first_name, last_name, street_address, zip_code, city + FROM customer + ) AS customer + ON orders.customer_no = customer.customer_no + WHERE order_id = $1`; - // const paymentQuery = `` + const paymentQuery = ` + SELECT 'stripe' as type, stripe_transaction_id, created, updated, stripe_status, amount, amount_received, amount_captured, amount_refunded + FROM stripe_payments + WHERE order_id = $1`; const orderQuery = ` -SELECT order_id as orderId, created, updated, status -FROM orders -WHERE order_id = $1`; + SELECT order_id as orderId, created, updated, status + FROM orders + WHERE order_id = $1`; const lineItemsQuery = ` -SELECT product.name, product.image, orders_lineitem.quantity, product_sku.sku_id, product_sku.price, product_sku.size -FROM orders_lineitem -INNER JOIN product -ON orders_lineitem.product_no = product.product_no -INNER JOIN product_sku -ON orders_lineitem.product_sku_no = product_sku.sku_id -WHERE orders_lineitem.order_id = $1`; + SELECT product.name, orders_lineitem.quantity, image.url as image, orders_lineitem.price, product_sku.sku_id, product_sku.size + FROM orders_lineitem + INNER JOIN product + ON orders_lineitem.product_no = product.product_no + INNER JOIN product_sku + ON orders_lineitem.product_sku_no = product_sku.sku_id + LEFT JOIN image + ON product.product_no = image.product_no + WHERE orders_lineitem.order_id = $1 AND image.default_image = TRUE`; - const shippingQuery = ` -SELECT shipping_company as company, tracking_code, tracking_link, user_notified -FROM shipping -WHERE shipping.order_id = $1`; - - const [order, customer, shipping, lineItems] = await Promise.all([ + const [order, customer, shipping, lineItems, payment] = await Promise.all([ this.database.get(orderQuery, [orderId]), this.database.get(customerQuery, [orderId]), - this.database.get(shippingQuery, [orderId]), + shippingRepository.getByOrderId(orderId), this.database.all(lineItemsQuery, [orderId]), + this.database.get(paymentQuery, [orderId]), ]); return { ...order, customer, shipping, + payment, lineItems, }; } async getOrder(orderId): Promise { const orderQuery = ` -SELECT order_id, customer_no, created, updated, status -FROM orders -WHERE order_id = $1`; + SELECT order_id, customer_no, created, updated, status + FROM orders + WHERE order_id = $1`; const lineItemsQuery = ` -SELECT product.name, product.image, orders_lineitem.quantity, product_sku.sku_id, product_sku.price, product_sku.size -FROM orders_lineitem -INNER JOIN product -ON orders_lineitem.product_no = product.product_no -INNER JOIN product_sku -ON orders_lineitem.product_sku_no = product_sku.sku_id -WHERE orders_lineitem.order_id = $1`; + SELECT product.name, image.url as image, orders_lineitem.quantity, product_sku.sku_id, product_sku.price, product_sku.size + FROM orders_lineitem + INNER JOIN product + ON orders_lineitem.product_no = product.product_no + INNER JOIN product_sku + ON orders_lineitem.product_sku_no = product_sku.sku_id + LEFT JOIN image + ON product.product_no = image.product_no + WHERE orders_lineitem.order_id = $1 AND image.default_image = TRUE`; const [order, lineItems] = await Promise.all([ this.database.get(orderQuery, [orderId]), @@ -346,7 +338,6 @@ WHERE orders_lineitem.order_id = $1`; } reduceSkuInStock(sku_id: number, quantity: number) { - console.log("reducing stock for sku_id:", sku_id, quantity); const query = `UPDATE product_sku SET stock = stock - $2 WHERE sku_id = $1`; diff --git a/src/product.ts b/src/product.ts index 636183f..4ca9963 100644 --- a/src/product.ts +++ b/src/product.ts @@ -9,76 +9,84 @@ class ProductRepository { async add( name = "foo", - description = "foo", - image = "/static/no-product.png", - subtext = "foo", - primary_color = "foo" + description = "foo baz", + subtext = "foo bar", + primary_color = "#E6E0DC" ) { const query = ` - INSERT INTO - product (name, description, image, subtext, primary_color) - VALUES ($1, $2, $3, $4, $5) - `; + INSERT INTO + product (name, description, subtext, primary_color) + VALUES ($1, $2, $3, $4)`; await this.database.update(query, [ name, description, - image, subtext, primary_color, ]); - const productIdQuery = `SELECT currval('product_product_no_seq')`; - const productCurrVal = await this.database.get(productIdQuery, []); + const productNumberQuery = `SELECT currval('product_product_no_seq')`; + const productCurrVal = await this.database.get(productNumberQuery, []); return productCurrVal.currval; } getAllProducts() { const query = ` - SELECT product.*, variations - FROM product - JOIN ( - SELECT product_no, count(size) AS variations - FROM product_sku - GROUP BY product_no - ) AS product_sku - ON product.product_no = product_sku.product_no`; + SELECT product.*, image.url as image, variations + FROM product + JOIN ( + SELECT product_no, count(size) AS variations + FROM product_sku + WHERE unlisted = FALSE + GROUP BY product_no + ) AS product_sku + ON product.product_no = product_sku.product_no + LEFT JOIN image + ON product.product_no = image.product_no + WHERE default_image = TRUE + ORDER BY product.updated DESC`; return this.database.all(query, []); } - async get(productId) { + async get(product_no) { const productQuery = `SELECT * FROM product WHERE product_no = $1`; - const product = await this.database.get(productQuery, [productId]); - const skuQuery = ` -SELECT sku_id, size, price, stock, default_price, updated, created -FROM product_sku -WHERE product_no = $1 -ORDER BY created`; + SELECT sku_id, size, price, stock, default_price, updated, created + FROM product_sku + WHERE product_no = $1 AND unlisted = FALSE + ORDER BY created`; + const imageQuery = ` + SELECT image_id, url, default_image + FROM image + WHERE product_no = $1 + ORDER BY default_image DESC`; + + const product = await this.database.get(productQuery, [product_no]); + const productSkus = await this.database.all(skuQuery, [product_no]); + const images = await this.database.all(imageQuery, [product_no]); - const productSkus = await this.database.all(skuQuery, [productId]); return Promise.resolve({ ...product, variations: productSkus, + images, }); } async addSku( - productId, + product_no, price = 10, size = "", stock = 0, defaultPrice = false ) { const query = ` -INSERT INTO -product_sku (product_no, price, size, stock, default_price) -VALUES ($1, $2, $3, $4, $5) -`; + INSERT INTO + product_sku (product_no, price, size, stock, default_price) + VALUES ($1, $2, $3, $4, $5)`; return this.database.update(query, [ - productId, + product_no, price, size, stock, @@ -86,50 +94,45 @@ VALUES ($1, $2, $3, $4, $5) ]); } - getSkus(productId) { + getSkus(product_no) { const q = `SELECT sku_id, product_no, price, size, stock, default_price, created, updated - FROM product_sku - WHERE product_no = $1 - ORDER BY created`; + FROM product_sku + WHERE product_no = $1 AND unlisted = FALSE + ORDER BY created`; - return this.database.all(q, [productId]); + return this.database.all(q, [product_no]); } - async getSkuStock(skuId) { + async getSkuStock(sku_id) { const query = "SELECT stock FROM product_sku WHERE sku_id = $1"; - const stockResponse = await this.database.get(query, [skuId]); + const stockResponse = await this.database.get(query, [sku_id]); return stockResponse?.stock || null; } // helper - async hasQuantityOfSkuInStock(skuId, quantity) { - const stock = await this.getSkuStock(skuId); + async hasQuantityOfSkuInStock(sku_id, quantity) { + const stock = await this.getSkuStock(sku_id); if (!stock) return false; // if requested quantity is less or equal to current stock return quantity <= stock; } - updateProduct(product) { - const { - product_no, - name, - description, - image, - subtext, - primary_color, - // new Date - } = product; + updateProduct( + product_no: string, + name: string, + description: string, + subtext: string, + primary_color: string + ) { const query = ` -UPDATE product -SET name = $1, description = $2, image = $3, subtext = $4, primary_color = $5, updated = to_timestamp($6 / 1000.0) -WHERE product_no = $7 -`; + UPDATE product + SET name = $1, description = $2, subtext = $3, primary_color = $4, updated = to_timestamp($5 / 1000.0) + WHERE product_no = $6`; return this.database.update(query, [ name, description, - image, subtext, primary_color, new Date().getTime(), @@ -137,66 +140,131 @@ WHERE product_no = $7 ]); } - updateSku(productId, skuId, stock = 0, size = 0, price = 10) { + updateSku(product_no, sku_id, stock = 0, size = 0, price = 10) { const query = ` -UPDATE product_sku -SET - price = $1, - size = $2, - stock = $3, - updated = to_timestamp($4 / 1000.0) -WHERE product_no = $5 and sku_id = $6`; - - console.log("update sql:", query, [ - price, - size, - stock, - new Date().getTime(), - productId, - skuId, - ]); + UPDATE product_sku + SET + price = $1, + size = $2, + stock = $3, + updated = to_timestamp($4 / 1000.0) + WHERE product_no = $5 and sku_id = $6`; return this.database.update(query, [ price, size, stock, new Date().getTime(), - productId, - skuId, + product_no, + sku_id, ]); } - async setSkuDefaultPrice(productId, skuId) { + async setSkuDefaultPrice(product_no, sku_id) { const resetOld = ` -UPDATE product_sku -SET default_price = false, updated = to_timestamp($1 / 1000.0) -WHERE product_no = $2 and default_price = true`; + UPDATE product_sku + SET default_price = false, updated = to_timestamp($1 / 1000.0) + WHERE product_no = $2 and default_price = true`; const setNew = ` -UPDATE product_sku -SET default_price = true, updated = to_timestamp($1 / 1000.0) -WHERE product_no = $2 AND sku_id = $3 -`; + UPDATE product_sku + SET default_price = true, updated = to_timestamp($1 / 1000.0) + WHERE product_no = $2 AND sku_id = $3`; - await this.database.update(resetOld, [new Date().getTime(), productId]); + await this.database.update(resetOld, [new Date().getTime(), product_no]); await this.database.update(setNew, [ new Date().getTime(), - productId, - skuId, + product_no, + sku_id, ]); } - getDefaultPrice(productId, skuId) { + getDefaultPrice(product_no, sku_id) { const query = ` -SELECT * -FROM product_sku -WHERE default_price = true and product_no = $1 and sku_id = $2`; + SELECT * + FROM product_sku + WHERE default_price = true and product_no = $1 and sku_id = $2`; - return this.database.query(query, [productId, skuId]); + return this.database.query(query, [product_no, sku_id]); } - deleteSku(productId, skuId) { - const query = `DELETE from product_sku WHERE product_no = $1 AND sku_id = $2`; - return this.database.update(query, [productId, skuId]); + deleteSku(product_no, sku_id) { + const query = ` + UPDATE product_sku + SET unlisted = TRUE + WHERE product_no = $1 AND sku_id = $2`; + + return this.database.update(query, [product_no, sku_id]); + } + + addImage(product_no, url: string) { + const query = ` + INSERT INTO image (product_no, url) + VALUES ($1, $2) + RETURNING image_id`; + + return this.database.get(query, [product_no, url]); + } + + getImages(product_no) { + const query = ` + SELECT image_id, url, default_image + FROM image + WHERE product_no = $1 + ORDER BY created`; + + return this.database.all(query, [product_no]); + } + + async setDefaultImage(product_no, image_id) { + const resetDefaultImageQuery = ` + UPDATE image + SET default_image = false, updated = to_timestamp($1 / 1000.0) + WHERE product_no = $2 and default_image = true`; + + const setNewDefaultImageQuery = ` + UPDATE image + SET default_image = true, updated = to_timestamp($1 / 1000.0) + WHERE product_no = $2 AND image_id = $3`; + + await this.database.update(resetDefaultImageQuery, [ + new Date().getTime(), + product_no, + ]); + await this.database.update(setNewDefaultImageQuery, [ + new Date().getTime(), + product_no, + image_id, + ]); + } + + async deleteImage(product_no, image_id) { + const isDefaultImageQuery = ` + SELECT default_image + FROM image + WHERE product_no = $1 AND image_id = $2`; + + const isDefaultImage = await this.database.get(isDefaultImageQuery, [ + product_no, + image_id, + ]); + if (isDefaultImage) { + const images = await this.getImages(product_no); + const imagesWithoutAboutToDelete = images.filter( + (image) => image.image_id !== image_id + ); + if (imagesWithoutAboutToDelete.length > 0) { + await this.setDefaultImage( + product_no, + imagesWithoutAboutToDelete[0].image_id + ); + } + } + + const query = ` + DELETE FROM image + WHERE product_no = $1 AND image_id = $2`; + + return this.database.update(query, [product_no, image_id]); } } diff --git a/src/shipping/index.ts b/src/shipping/index.ts new file mode 100644 index 0000000..5f7aa07 --- /dev/null +++ b/src/shipping/index.ts @@ -0,0 +1,90 @@ +import logger from "../logger"; +import establishedDatabase from "../database"; +import PostenRepository from "./posten"; + +const posten = new PostenRepository(); + +class ShippingRepository { + database: typeof establishedDatabase; + + constructor(database = establishedDatabase) { + this.database = database || establishedDatabase; + } + + async track(trackingCode: string) { + const trackResponse = await posten.track(trackingCode); + console.log("trackResponse:", trackResponse); + + return trackResponse; + } + + async create(orderId: string) { + const query = ` + INSERT INTO shipment (order_id, courier_id) + VALUES ($1, NULL) + RETURNING shipment_id`; + + return this.database.get(query, [orderId]); + } + + async update( + shipmentId: string, + courierId: string, + trackingCode: string, + trackingLink: string + ) { + const query = ` + UPDATE shipment + SET courier_id = $1, tracking_code = $2, tracking_link = $3 + WHERE shipment_id = $4 + RETURNING shipment_id`; + + return this.database.get(query, [ + courierId, + trackingCode, + trackingLink, + shipmentId, + ]); + } + + async get(shipmentId: string) { + const query = ` + SELECT shipment_id, order_id, shipment_courier.name as courier, shipment_courier.has_api, shipment_courier.shipment_courier_id as courier_id, tracking_code, tracking_link, user_notified + FROM shipment + LEFT JOIN shipment_courier + ON shipment.courier_id = shipment_courier.shipment_courier_id + WHERE shipment_id = $1`; + + return this.database.get(query, [shipmentId]); + } + + async getByOrderId(orderId: string) { + const query = ` + SELECT shipment_id, order_id, shipment_courier.name as courier, shipment_courier.has_api, shipment_courier.shipment_courier_id as courier_id, tracking_code, tracking_link, user_notified + FROM shipment + INNER JOIN shipment_courier + ON shipment.courier_id = shipment_courier.shipment_courier_id + WHERE order_id = $1`; + + return this.database.get(query, [orderId]); + } + + getCourier(courierId: string) { + const query = ` + SELECT shipment_courier_id AS courier_id, name, website, has_api + FROM shipment_courier + WHERE shipment_courier_id = $1`; + + return this.database.get(query, [courierId]); + } + + getAllCouriers() { + const query = ` + SELECT shipment_courier_id AS courier_id, name, website, has_api + FROM shipment_courier`; + + return this.database.all(query, []); + } +} + +export default ShippingRepository; diff --git a/src/shipping/posten.ts b/src/shipping/posten.ts new file mode 100644 index 0000000..0f2e5b1 --- /dev/null +++ b/src/shipping/posten.ts @@ -0,0 +1,59 @@ +import { PostenInvalidQueryError, PostenNotFoundError } from "../errors/posten"; + +class Posten { + trackUrl: string; + + constructor() { + this.trackUrl = "https://sporing.posten.no/tracking/api/fetch"; + } + + track(id: string) { + const url = new URL(this.trackUrl); + url.searchParams.append("query", id); + url.searchParams.append("lang", "no"); + + return fetch(url.href) + .then((resp) => resp.json()) + .then((response) => { + if (response?.errorCode && response?.errorCode === "2404") { + throw new PostenNotFoundError(response); + } + + if ( + response?.consignmentSet?.[0]?.error && + response?.consignmentSet?.[0]?.error?.code === 400 + ) { + throw new PostenInvalidQueryError(response); + } + + return response; + }) + .then((response) => Posten.transformPostenResponse(response)); + } + + static transformPostenResponse(response: object) { + const l = response?.consignmentSet?.[0]; + // delete l.packageSet + return { + weight: l.totalWeightInKgs, + name: l.packageSet?.[0].productName, + events: l.packageSet?.[0].eventSet?.map((event) => { + return { + description: event.description, + city: event.city, + country: event.country, + countryCode: event.countryCode, + date: event.dateIso, + status: Posten.formatEventStatus(event.status), + }; + }), + }; + } + + static formatEventStatus(status: string) { + const s = status.toLowerCase()?.replaceAll("_", " "); + return s.charAt(0).toUpperCase() + s.slice(1); + } +} + +export default Posten; diff --git a/src/stripe/stripeApi.ts b/src/stripe/stripeApi.ts index 37376af..5279289 100644 --- a/src/stripe/stripeApi.ts +++ b/src/stripe/stripeApi.ts @@ -19,12 +19,12 @@ class StripeApi { } async createPaymentIntent( - clientId: string, + planet_id: string, total: number, orderId: string, customer: ICustomer ): Promise> { - const stripeCustomer = await this.createCustomer(clientId, customer); + const stripeCustomer = await this.createCustomer(planet_id, customer); const paymentIntent = await this.stripe.paymentIntents.create({ customer: stripeCustomer?.id, amount: total * 100, @@ -34,7 +34,7 @@ class StripeApi { address: stripeCustomer.address, }, metadata: { - clientId, + planet_id, orderId, }, }); @@ -42,7 +42,7 @@ class StripeApi { return paymentIntent; } - async createCustomer(clientId: string, customer: ICustomer) { + async createCustomer(planet_id: string, customer: ICustomer) { return await this.stripe.customers.create({ email: customer.email, name: `${customer.first_name} ${customer.last_name}`, @@ -52,7 +52,7 @@ class StripeApi { postal_code: String(customer.zip_code), }, metadata: { - clientId, + planet_id, }, }); } diff --git a/src/stripe/stripeRepository.ts b/src/stripe/stripeRepository.ts index e6d3a41..3f0c560 100644 --- a/src/stripe/stripeRepository.ts +++ b/src/stripe/stripeRepository.ts @@ -2,6 +2,7 @@ import establishedDatabase from "../database"; import Configuration from "../config/configuration"; import StripeApi from "./stripeApi"; import Stripe from "stripe"; +import logger from "../logger"; import type ICustomer from "../interfaces/ICustomer"; const configuration = Configuration.getInstance(); @@ -38,46 +39,59 @@ class StripeRepository { updatePaymentIntent(payload: Stripe.Response) { const query = ` UPDATE stripe_payments - SET stripe_status = $2, amount_received = $3, updated = $4 + SET stripe_status = $2, amount_received = $3, updated = $4, stripe_payment_response = $5 WHERE order_id = $1`; + logger.info("Updating stripe payment intent", { payment_intent: payload }); + return this.database.update(query, [ payload.metadata.orderId, payload.status, payload.amount_received, new Date(), + payload, ]); } updatePaymentCharge(payload: Stripe.Response) { const query = ` UPDATE stripe_payments - SET stripe_status = $2, amount_captured = $3, amount_refunded = $4, updated = $5 + SET stripe_status = $2, amount_captured = $3, amount_refunded = $4, updated = $5, stripe_charge_response = $6 WHERE order_id = $1 `; + logger.info("Updating stripe payment charge", { payment_charge: payload }); + return this.database.update(query, [ payload.metadata.orderId, payload.status, payload.amount_captured, payload.amount_refunded, new Date(), + payload, ]); } async createPayment( - clientId: string, + planet_id: string, total: number, orderId: string, customer: ICustomer ) { const paymentIntent = await stripeApi.createPaymentIntent( - clientId, + planet_id, total, orderId, customer ); + logger.info("Payment intent from stripe", { + payment_intent: paymentIntent, + planet_id, + order_id: orderId, + customer_no: customer.customer_no, + }); + return this.commitPaymentToDatabase(orderId, paymentIntent).then( () => paymentIntent.client_secret ); diff --git a/src/utils/generateUUID.ts b/src/utils/generateUUID.ts index 3bf49cb..afd0530 100644 --- a/src/utils/generateUUID.ts +++ b/src/utils/generateUUID.ts @@ -1,6 +1,6 @@ const hex = "0123456789abcdef"; -export default function generateClientId(len = 22) { +export default function generateUUID(len = 22) { let output = ""; for (let i = 0; i < len; ++i) { output += hex.charAt(Math.floor(Math.random() * hex.length)); diff --git a/src/warehouse.ts b/src/warehouse.ts index a9a2331..fcb6849 100644 --- a/src/warehouse.ts +++ b/src/warehouse.ts @@ -1,5 +1,8 @@ import establishedDatabase from "./database"; import type { IProductWithSkus } from "./interfaces/IProduct"; +import ProductRepository from "./product"; + +const productRepository = new ProductRepository(); // interface IProductSku { // id: string @@ -26,32 +29,35 @@ class WarehouseRepository { } async getProduct(productId): Promise { - const productQuery = `SELECT * FROM product WHERE product_no = $1`; - const product = await this.database.get(productQuery, [productId]); + return productRepository.get(productId); + } - const skuQuery = ` -SELECT sku_id, size, price, stock, default_price, updated, created -FROM product_sku -WHERE product_no = $1 -ORDER BY created`; + async getProductAudit(productId) { + const query = ` + SELECT table_name, row_data, changed_fields + FROM audit.logged_actions + WHERE table_name = 'product' + ORDER BY action_tstamp_stm DESC`; - const productSkus = await this.database.all(skuQuery, [productId]); - return Promise.resolve({ - ...product, - variations: productSkus, - }); + // TODO need to filter by product_id + + return this.database.all(query, []); } all(): Promise { const query = ` - SELECT product.*, variation_count, sum_stock - FROM product - INNER JOIN ( - SELECT product_no, count(size) AS variation_count, sum(stock) as sum_stock - FROM product_sku - GROUP BY product_no - ) AS product_sku - ON product.product_no = product_sku.product_no`; + SELECT product.*, image.url as image, variation_count, sum_stock + FROM product + LEFT JOIN ( + SELECT product_no, count(size) AS variation_count, sum(stock) as sum_stock + FROM product_sku + GROUP BY product_no + ) AS product_sku + ON product.product_no = product_sku.product_no + LEFT JOIN image + ON product.product_no = image.product_no + WHERE default_image = TRUE + ORDER BY product.updated DESC`; return this.database.all(query); } @@ -69,20 +75,18 @@ ORDER BY created`; createWarehouseProduct(skuId, stock) { const query = ` - INSERT INTO - warehouse (product_sku_id, stock) - VALUES ($1, $2) - `; + INSERT INTO + warehouse (product_sku_id, stock) + VALUES ($1, $2)`; return this.database.update(query, [skuId, stock]); } updateWarehouseProductSkuStock(skuId, stock) { const query = ` - UPDATE warehouse - SET stock = $1, updated = to_timestamp($2 / 1000.0) - WHERE product_sku_id = $3 - `; + UPDATE warehouse + SET stock = $1, updated = to_timestamp($2 / 1000.0) + WHERE product_sku_id = $3`; return this.database.update(query, [stock, new Date(), skuId]); } @@ -91,9 +95,9 @@ ORDER BY created`; const sqlStatus = status ? "TRUE" : "FALSE"; const query = ` - UPDATE warehouse - SET enabled = $1, updated = to_timestamp($2 / 1000.0) - WHERE product_sku_id = $3`; + UPDATE warehouse + SET enabled = $1, updated = to_timestamp($2 / 1000.0) + WHERE product_sku_id = $3`; return this.database.query(query, [sqlStatus, new Date(), skuId]); } diff --git a/src/webserver/controllerResponses.ts b/src/webserver/controllerResponses.ts new file mode 100644 index 0000000..b1df025 --- /dev/null +++ b/src/webserver/controllerResponses.ts @@ -0,0 +1,50 @@ +import logger from "../logger"; + +export function genericFailedResponse( + res, + message, + error, + status = 500, + attributes = {} +) { + logger.error(message, { + controller_error: error?.message, + controller_stack: error?.stack, + status_code: error?.statusCode || status, + ...attributes, + }); + + console.log("we have this:", { + controller_error: error?.message, + controller_stack: error?.stack, + status_code: error?.statusCode || status, + ...attributes, + }); + + return res.status(error?.statusCode || status).send({ + success: false, + message, + }); +} + +export function postenApiFailedResponse( + res, + message, + error, + status = 500, + attributes = {} +) { + logger.error(message, { + controller_error: error?.message, + controller_stack: error?.stack, + posten_error: error?.apiError, + status_code: error?.statusCode || status, + ...attributes, + }); + + return res.status(error?.statusCode || status).send({ + success: false, + message: error?.message || message, + reason: error?.reason, + }); +} diff --git a/src/webserver/controllers/orderController.ts b/src/webserver/controllers/orderController.ts index c4854a4..484f36b 100644 --- a/src/webserver/controllers/orderController.ts +++ b/src/webserver/controllers/orderController.ts @@ -3,12 +3,11 @@ import OrderRepository from "../../order"; import CustomerRepository from "../../customer"; import ProductRepository from "../../product"; import WarehouseRepository from "../../warehouse"; -import Cart from "../../cart/Cart"; import ICustomer from "../../interfaces/ICustomer"; import { validEmail } from "../../utils/formValidation"; -import ICart from "../../interfaces/ICart"; +import type ICart from "../../interfaces/ICart"; const orderRepository = new OrderRepository(); const customerRepository = new CustomerRepository(); diff --git a/src/webserver/controllers/productController.ts b/src/webserver/controllers/productController.ts index c5c0f80..0f2c3c5 100644 --- a/src/webserver/controllers/productController.ts +++ b/src/webserver/controllers/productController.ts @@ -1,8 +1,10 @@ import logger from "../../logger"; import ProductRepository from "../../product"; -const productRepository = new ProductRepository(); +import { ProductNotFoundError } from "../../errors/product"; import type { Request, Response } from "express"; +const productRepository = new ProductRepository(); + async function add(req: Request, res: Response) { logger.info("Adding new product"); try { @@ -25,28 +27,44 @@ async function add(req: Request, res: Response) { } } -function update(req: Request, res: Response) { +async function update(req: Request, res: Response) { const { product_id } = req.params; - logger.info("Updating product", { product_id }); + const { name, description, subtext, primary_color } = req.body; + logger.info("Updating product", { + product_id, + name, + description, + subtext, + primary_color, + }); - return productRepository - .get(product_id) - .then((product) => { - logger.info("Updated product", { product, product_id }); + try { + const product = await productRepository.get(product_id); + if (!product) { + throw new ProductNotFoundError(); + } - res.send({ - success: true, - product: product, - }); - }) - .catch((error) => { - logger.error("Error while updating product", { error, product_id }); - res.statusCode = error.statusCode || 500; - return res.send({ - success: false, - message: error?.message || "Unexpected error while updating product", - }); + await productRepository.updateProduct( + product_id, + name || product.name, + description || product.description, + subtext || product.subtext, + primary_color || product.primary_color + ); + const updatedProduct = await productRepository.get(product_id); + logger.info("Updated product", { product: updatedProduct, product_id }); + + res.send({ + success: true, + product: updatedProduct, }); + } catch (error) { + logger.error("Error while updating product", { error, product_id }); + res.status(error.statusCode || 500).send({ + success: false, + message: error?.message || "Unexpected error while updating product", + }); + } } function getAll(req: Request, res: Response) { @@ -224,6 +242,99 @@ async function setSkuDefaultPrice(req: Request, res: Response) { } } +async function addImage(req: Request, res: Response) { + const { product_id } = req.params; + const { url } = req.body; + logger.info("Adding new image", { product_id, url }); + + try { + await productRepository.addImage(product_id, url); + let images = await productRepository.getImages(product_id); + console.log("found images::::", images); + + if (!images?.find((image) => image.default_image === true)) { + await productRepository.setDefaultImage( + product_id, + images[images.length - 1].image_id + ); + + images[images.length - 1].default_image = true; + } + + logger.info("New images after add", { images, product_id }); + + res.send({ + success: true, + product_id, + images, + }); + } catch (error) { + logger.error("Error adding image", { error, product_id }); + res.statusCode = error?.statusCode || 500; + res.send({ + success: false, + message: error?.message || "Unexpected error while adding new image", + }); + } +} + +async function removeImage(req: Request, res: Response) { + const { product_id, image_id } = req.params; + + try { + await productRepository.deleteImage(product_id, image_id); + const images = await productRepository.getImages(product_id); + logger.info("New images after delete", { images, product_id, image_id }); + + res.send({ + success: true, + images, + }); + } catch (error) { + logger.error("Error deleting image", { product_id, image_id, error }); + res.statusCode = error?.statusCode || 500; + + res.send({ + success: false, + message: error?.message || "Unexpected error while deleting image", + }); + } +} + +async function setDefaultImage(req: Request, res: Response) { + const { product_id, image_id } = req.params; + const { url } = req.body; + logger.info("Updating new default image", { product_id, image_id }); + + try { + await productRepository.setDefaultImage(product_id, image_id); + let images = await productRepository.getImages(product_id); + logger.info("New images after update default image", { + images, + product_id, + image_id, + }); + + res.send({ + success: true, + product_id, + images, + }); + } catch (error) { + logger.error("Unexpected error while setting default image", { + error, + product_id, + image_id, + }); + res.statusCode = error?.statusCode || 500; + res.send({ + success: false, + message: + error?.message || "Unexpected error while adding setting default image", + }); + } +} + export default { add, update, @@ -234,4 +345,7 @@ export default { updateSku, deleteSku, setSkuDefaultPrice, + addImage, + removeImage, + setDefaultImage, }; diff --git a/src/webserver/controllers/shipmentController.ts b/src/webserver/controllers/shipmentController.ts new file mode 100644 index 0000000..d4d3f72 --- /dev/null +++ b/src/webserver/controllers/shipmentController.ts @@ -0,0 +1,242 @@ +import logger from "../../logger"; +import ShippingRepository from "../../shipping"; +import { + genericFailedResponse, + postenApiFailedResponse, +} from "../controllerResponses"; +import type { Request, Response } from "express"; + +const shippingRepository = new ShippingRepository(); + +async function create(req: Request, res: Response) { + const { order_id } = req.params; + logger.info("Creating shipment for order", { order_id }); + + try { + const { shipment_id } = await shippingRepository.create(order_id); + logger.info("Shipment created", { shipment_id, order_id }); + + const shipment = await shippingRepository.get(shipment_id); + + res.send({ + success: true, + message: "Successfully created shipment on order", + shipment, + }); + } catch (error) { + genericFailedResponse( + res, + "Unexpected error creating shipment on order", + error + ); + } +} + +async function update(req: Request, res: Response) { + const { shipment_id } = req.params; + const { courier_id, tracking_code, tracking_link } = req.body; + logger.info("Updating shipment", { + shipment_id, + courier_id, + tracking_code, + tracking_link, + }); + + try { + const shipment = await shippingRepository.get(shipment_id); + if (!shipment) { + return genericFailedResponse( + res, + "Shipment not found, unable to update", + null, + 404, + { shipment_id } + ); + } + + const courier = await shippingRepository.getCourier(courier_id); + if (!courier) { + return genericFailedResponse( + res, + "Unable to update shipment, selected courier not found", + null, + 404, + { shipment_id } + ); + } + + await shippingRepository.update( + shipment_id, + courier_id, + tracking_code, + tracking_link + ); + const newShipment = await shippingRepository.get(shipment_id); + + return res.send({ + success: true, + message: "Successfully updated shipment", + shipment: newShipment, + }); + } catch (error) { + genericFailedResponse( + res, + "Unexpected error while updating shipment", + error, + null, + { shipment_id } + ); + } +} + +async function get(req, res) { + const { shipment_id } = req.params; + logger.info("Getting shipment by id", { shipment_id }); + + // get a shipment + let shipment = null; + try { + shipment = await shippingRepository.get(shipment_id); + logger.info("Found shipment", { shipment }); + + if (shipment === undefined) { + return genericFailedResponse( + res, + `No shipment with id ${shipment_id} found`, + null, + 404, + { shipment_id } + ); + } + + res.send({ + success: true, + shipment, + }); + } catch (error) { + genericFailedResponse( + res, + `Unexpected error while looking for shipment`, + error, + 500, + { shipment_id } + ); + } +} + +async function track(req, res) { + const { shipment_id } = req.params; + logger.info("Tracking shipment by id", { shipment_id }); + + if (isNaN(Number(shipment_id))) { + return genericFailedResponse( + res, + "Shipment id must be a number", + null, + 400 + ); + } + + // get a shipment + let shipment = null; + let trackedShipment = null; + try { + shipment = await shippingRepository.get(shipment_id); + + if (!shipment?.tracking_code) { + return genericFailedResponse( + res, + "No tracking code registered on shipment", + null, + 404, + { shipment_id } + ); + } + + logger.info("Found tracking code", { + tracking_code: shipment.tracking_code, + }); + try { + trackedShipment = await shippingRepository.track(shipment?.tracking_code); + logger.info("Found tracked shipment", { + tracked_shipment: trackedShipment, + }); + } catch (error) { + return postenApiFailedResponse( + res, + "Unexpected error from posten API while tracking shipment", + error, + 500, + { shipment_id } + ); + } + + res.send({ + success: true, + shipment: trackedShipment, + }); + } catch (error) { + genericFailedResponse( + res, + "Unexpected error while tracking shipment", + error, + 500, + { shipment_id } + ); + } +} + +function allCouriers(req: Request, res: Response) { + return shippingRepository.getAllCouriers().then((couriers) => + res.send({ + success: true, + message: "All registered shipment couriers", + couriers, + }) + ); +} + +function getCourier(req: Request, res: Response) { + const { courier_id } = req.params; + + if (isNaN(Number(courier_id))) { + return genericFailedResponse(res, "Courier id must be a number", null, 400); + } + + return shippingRepository + .getCourier(courier_id) + .then((courier) => { + if (courier === undefined) { + return genericFailedResponse( + res, + `No courier with that id found`, + null, + 404, + { courier_id } + ); + } + + res.send({ + success: true, + courier, + }); + }) + .catch((error) => { + genericFailedResponse( + res, + "Unexpected error happend while trying to get courier by id", + error, + 500, + { courier_id } + ); + }); +} + +export default { + create, + update, + get, + track, + allCouriers, + getCourier, +}; diff --git a/src/webserver/controllers/stripePaymentController.ts b/src/webserver/controllers/stripePaymentController.ts index d407f85..760e531 100644 --- a/src/webserver/controllers/stripePaymentController.ts +++ b/src/webserver/controllers/stripePaymentController.ts @@ -6,6 +6,7 @@ import StripeApi from "../../stripe/stripeApi"; import StripeRepository from "../../stripe/stripeRepository"; import OrderRepository from "../../order"; import CustomerRepository from "../../customer"; +import EmailRepository from "../../email"; import type { Request, Response, NextFunction } from "express"; import type { IOrder, ILineItem } from "../../interfaces/IOrder"; @@ -18,13 +19,14 @@ const stripeApi = new StripeApi(stripePublicKey, stripeSecretKey); const stripeRepository = new StripeRepository(); const orderRepository = new OrderRepository(); const customerRepository = new CustomerRepository(); +const emailRepository = new EmailRepository(); async function create(req, res) { - const clientId = req?.planetId; + const planet_id = req?.planet_id; const { order_id, customer_no } = req.body; logger.info("Creating stripe payment intent", { - client_id: clientId, + planet_id, order_id, customer_no, }); @@ -51,10 +53,10 @@ async function create(req, res) { ); stripeRepository - .createPayment(clientId, sum, order_id, customer) + .createPayment(planet_id, sum, order_id, customer) .then((clientSecret) => { logger.info("New stripe payment", { - client_id: clientId, + planet_id, client_secret: clientSecret, }); @@ -67,7 +69,7 @@ async function create(req, res) { res.statusCode = error?.statusCode || 500; logger.error("Error creating stripe payment intent", { error, - client_id: clientId, + planet_id, customer_no, order_id, }); @@ -82,7 +84,6 @@ async function create(req, res) { } async function updatePayment(req: Request, res: Response) { - console.log("STRIPE WEBHOOK body:", req.body); const { type, data } = req.body; const { object } = data; const { orderId } = object?.metadata; @@ -93,28 +94,36 @@ async function updatePayment(req: Request, res: Response) { } if (!orderId) { - console.log("no order_id found in webhook, nothing to do"); + logger.warning("no order_id found in webhook from stripe, nothing to do", { + stripe_webhook_type: type, + [type]: object, + }); return res.status(200).send("ok"); } if (type === "payment_intent.created") { console.log("handle payment intent created... doing nothing"); } else if (type === "payment_intent.succeeded") { - console.log("handle payment succeeded", object); await stripeRepository.updatePaymentIntent(object); - orderRepository.confirmOrder(orderId); + await orderRepository.confirmOrder(orderId); + const { customer, lineItems } = await orderRepository.getOrderDetailed( + orderId + ); + await emailRepository.sendConfirmation(orderId, customer, lineItems); } else if (type === "payment_intent.payment_failed") { console.log("handle payment failed", object); await stripeRepository.updatePaymentIntent(object); - orderRepository.cancelOrder(orderId); + await orderRepository.cancelOrder(orderId); } else if (type === "charge.succeeded") { - console.log("handle charge succeeded", object); await stripeRepository.updatePaymentCharge(object); } else if (type === "charge.refunded") { - console.log("handle charge refunded", object); await stripeRepository.updatePaymentCharge(object); await orderRepository.refundOrder(orderId); } else { + logger.warning("unhandled webhook from stripe", { + stripe_webhook_type: type, + [type]: object, + }); console.log(`webhook for ${type}, not setup yet`); } diff --git a/src/webserver/controllers/warehouseController.ts b/src/webserver/controllers/warehouseController.ts index 1f5a090..880f25e 100644 --- a/src/webserver/controllers/warehouseController.ts +++ b/src/webserver/controllers/warehouseController.ts @@ -30,15 +30,15 @@ function getAll(req: Request, res: Response) { } function getProduct(req: Request, res: Response) { - const { productId } = req.params; - logger.info("Fetching warehouse product", { product_id: productId }); + const { product_id } = req.params; + logger.info("Fetching warehouse product", { product_id }); return warehouseRepository - .getProduct(productId) + .getProduct(product_id) .then((product) => { logger.info("Found warehouse product", { product, - product_id: productId, + product_id, }); res.send({ @@ -49,7 +49,7 @@ function getProduct(req: Request, res: Response) { .catch((error) => { logger.error("Error fetching warehouse product:", { error, - product_id: productId, + product_id, }); res.statusCode = error.statusCode || 500; @@ -57,9 +57,31 @@ function getProduct(req: Request, res: Response) { success: false, message: error?.message || - `Unexpected error while fetching product with id: ${productId}`, + `Unexpected error while fetching product with id: ${product_id}`, }); }); } -export default { getAll, getProduct }; +function getProductAudit(req: Request, res: Response) { + const { product_id } = req.params; + logger.info("Fetching audit logs for product", { product_id }); + + return warehouseRepository + .getProductAudit(product_id) + .then((auditLogs) => + res.send({ + success: true, + logs: auditLogs, + }) + ) + .catch((error) => { + logger.error("Unexpected error while fetching product audit log", error); + + res.status(error?.statusCode || 500).send({ + success: false, + message: "Unexpected error while fetching product audit log", + }); + }); +} + +export default { getAll, getProduct, getProductAudit }; diff --git a/src/webserver/middleware/getOrSetCookieForClient.ts b/src/webserver/middleware/getOrSetCookieForClient.ts index 92f87c6..78b776e 100644 --- a/src/webserver/middleware/getOrSetCookieForClient.ts +++ b/src/webserver/middleware/getOrSetCookieForClient.ts @@ -3,14 +3,14 @@ import generateUUID from "../../utils/generateUUID"; import httpContext from "express-http-context"; import type { Request, Response, NextFunction } from "express"; -const cookieClientKey = "planetId"; +const cookieClientKey = "planet_id"; const cookieOptions = { path: "/", maxAge: 60 * 60 * 24 * 7, // 7 days }; -function setClientIdCookieHeader(res: Response, value: string) { - const setCookie = cookie.serialize("planetId", value, cookieOptions); +function setplanet_idCookieHeader(res: Response, value: string) { + const setCookie = cookie.serialize("planet_id", value, cookieOptions); return res.setHeader("Set-Cookie", setCookie); } @@ -20,16 +20,16 @@ const getOrSetCookieForClient = ( next: NextFunction ) => { const cookies = cookie.parse(req.headers.cookie || ""); - const planetId = cookies[cookieClientKey]; + let planet_id = cookies[cookieClientKey]; - if (planetId) { - req.planetId = planetId; - httpContext.set("planetId", planetId); + if (planet_id) { + req.planet_id = planet_id; + httpContext.set("planet_id", planet_id); return next(); } - const clientId = generateUUID(); - setClientIdCookieHeader(res, clientId); + planet_id = generateUUID(); + setplanet_idCookieHeader(res, planet_id); next(); }; diff --git a/src/webserver/middleware/setupHeaders.ts b/src/webserver/middleware/setupHeaders.ts index 5736ea2..ab87c0a 100644 --- a/src/webserver/middleware/setupHeaders.ts +++ b/src/webserver/middleware/setupHeaders.ts @@ -15,7 +15,7 @@ const mapFeaturePolicyToString = (features) => { const setupHeaders = (req: Request, res: Response, next: NextFunction) => { res.set("Access-Control-Allow-Headers", "Content-Type"); - res.set("Access-Control-Allow-Methods", "POST, PATCH, DELETE"); + res.set("Access-Control-Allow-Methods", "POST, PATCH, DELETE, PUT"); // Security res.set("X-Content-Type-Options", "nosniff"); diff --git a/src/webserver/server.ts b/src/webserver/server.ts index a7445ac..ff94cca 100644 --- a/src/webserver/server.ts +++ b/src/webserver/server.ts @@ -11,6 +11,7 @@ import ProductController from "./controllers/productController"; import WarehouseController from "./controllers/warehouseController"; import StripePaymentController from "./controllers/stripePaymentController"; import LoginController from "./controllers/loginController"; +import ShipmentController from "./controllers/shipmentController"; // middleware import httpContext from "express-http-context"; @@ -35,31 +36,50 @@ router.post("/logout", LoginController.logout); router.get("/products", ProductController.getAll); router.post("/product", ProductController.add); router.get("/product/:product_id", ProductController.getById); +router.put("/product/:product_id", ProductController.update); router.post("/product/:product_id/sku", ProductController.addSku); -router.patch("/product/:product_id/sku/:sku_id", ProductController.updateSku); +router.put("/product/:product_id/sku/:sku_id", ProductController.updateSku); router.delete("/product/:product_id/sku/:sku_id", ProductController.deleteSku); router.post( - "/product/:product_id/sku/:sku_id/default_price", + "/product/:product_id/sku/:sku_id/default", ProductController.setSkuDefaultPrice ); +router.post("/product/:product_id/image", ProductController.addImage); +router.delete( + "/product/:product_id/image/:image_id", + ProductController.removeImage +); +router.post( + "/product/:product_id/image/:image_id/default", + ProductController.setDefaultImage +); router.get("/orders", OrderController.getAll); router.post("/order", OrderController.createOrder); router.get("/order/:order_id", OrderController.get); router.get("/order/:order_id/status", OrderController.getOrderStatus); +// router.post("/order/:order_id/confirm", adminMiddleware, OrderController.confirmOrder); + +router.get("/shipment/couriers", ShipmentController.allCouriers); +router.get("/shipment/courier/:courier_id", ShipmentController.getCourier); +router.get("/shipment/:shipment_id", ShipmentController.get); +router.get("/shipment/:shipment_id/track", ShipmentController.track); +router.post("/shipment/:order_id", ShipmentController.create); +router.put("/shipment/:shipment_id", ShipmentController.update); router.get("/warehouse", WarehouseController.getAll); -router.get("/warehouse/:productId", WarehouseController.getProduct); -// router.get("/api/order/:id", OrderController.getOrderById); -// router.post("/api/order/:id/cancel", OrderController.cancelOrder); -// router.post("/api/order/:id/extend", OrderController.extendOrder); +router.get("/warehouse/:product_id", WarehouseController.getProduct); +router.get("/warehouse/:product_id/audit", WarehouseController.getProductAudit); +// router.get("/order/:id", OrderController.getOrderById); +// router.post("/order/:id/cancel", OrderController.cancelOrder); +// router.post("/order/:id/extend", OrderController.extendOrder); router.post("/payment/stripe", StripePaymentController.create); router.post("/webhook/stripe", StripePaymentController.updatePayment); -router.get("/", (req, res) => res.send("hello")); +router.get("/", (req, res) => res.send("hi")); -app.use("/api", router); +app.use("/api/v1", router); const server = createServer(app); server.listen(port, () => logger.info(`Server started, listening at :${port}`)); diff --git a/src/webserver/websocketCartServer.ts b/src/webserver/websocketCartServer.ts index 7ecde60..9cb907e 100644 --- a/src/webserver/websocketCartServer.ts +++ b/src/webserver/websocketCartServer.ts @@ -27,13 +27,13 @@ function setupCartWebsocketServer(server) { wss.on("connection", (ws: Websocket, req: Request) => { const sessionId = generateUUID(); - const clientId = - getCookieValue(req.headers.cookie, "planetId") || - getHeaderValue(req.url, "planetId"); + const planet_id = + getCookieValue(req.headers.cookie, "planet_id") || + getHeaderValue(req.url, "planet_id"); - if (clientId === null) return; + if (planet_id === null) return; - const wsCart = new WSCart(ws, clientId); + const wsCart = new WSCart(ws, planet_id); wsCart.cartSession = cartSession; cartSession.add(sessionId, wsCart);