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
This commit is contained in:
2022-12-29 19:25:39 +01:00
committed by GitHub
parent 66bd6af861
commit a28b47413a
44 changed files with 1719 additions and 386 deletions

View File

@@ -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<Array<object>> {
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]);
}
}

View File

@@ -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,
});

View File

@@ -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);
}
}

View File

@@ -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';
// ```;

View File

@@ -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';

View File

@@ -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 *

View File

@@ -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;

View File

@@ -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
)
);
CREATE TRIGGER trigger_customerno_genid
BEFORE INSERT ON customer
FOR EACH ROW EXECUTE PROCEDURE unique_customer_no();

View File

@@ -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;
ON orders.customer_no = customer.customer_no;

View File

@@ -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,

View File

@@ -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
);

View File

@@ -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) {

View File

@@ -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"
}
}
]

View File

@@ -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",

View File

@@ -3,7 +3,7 @@
"model": "cart",
"pk": 1,
"fields": {
"client_id": "800020696e96800f8904ea"
"planet_id": "800020696e96800f8904ea"
}
},
{

View File

@@ -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
}
}
]

View File

@@ -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"
}
}
]

View File

@@ -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"
}
}
]

View File

@@ -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"
}
}
]

56
src/email.ts Normal file
View File

@@ -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;

29
src/errors/posten.ts Normal file
View File

@@ -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;
}
}

10
src/errors/product.ts Normal file
View File

@@ -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;
}
}

2
src/index.d.ts vendored
View File

@@ -3,7 +3,7 @@ export {};
declare global {
namespace Express {
export interface Request {
planetId?: string;
planet_id?: string;
}
}
}

View File

@@ -1,5 +1,5 @@
export default interface ICart {
client_id: string;
planet_id: string;
cart_id: number;
lineitem_id: number;
quantity: number;

View File

@@ -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;
}

View File

@@ -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()),
}),
],

View File

@@ -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<IOrder> {
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`;

View File

@@ -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]);
}
}

90
src/shipping/index.ts Normal file
View File

@@ -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;

59
src/shipping/posten.ts Normal file
View File

@@ -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;

View File

@@ -19,12 +19,12 @@ class StripeApi {
}
async createPaymentIntent(
clientId: string,
planet_id: string,
total: number,
orderId: string,
customer: ICustomer
): Promise<Stripe.Response<Stripe.PaymentIntent>> {
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,
},
});
}

View File

@@ -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<Stripe.PaymentIntent>) {
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<Stripe.Charge>) {
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
);

View File

@@ -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));

View File

@@ -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<IProductWithSkus> {
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<IProductWithSkus[]> {
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]);
}

View File

@@ -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,
});
}

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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`);
}

View File

@@ -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 };

View File

@@ -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();
};

View File

@@ -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");

View File

@@ -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}`));

View File

@@ -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);