Files
graphql-api-js/src/datasources/product-api.ts
T

826 lines
25 KiB
TypeScript

import { BaseSQLDataSource } from './BaseSQLDataSource';
import { camelcase } from 'stringcase';
import slug from 'slug';
import * as sql from './sql';
import {
Product,
ProductGroup,
PrintProduct,
ProductType,
ProductBlacklist,
ProductWallpaperType,
ProductFields,
Orientation,
ProductsFilterInput,
ProductInfoInput,
ProductBlacklistMarketInput,
ProductMaterials,
ProductSizeTypes,
ProductProportionsInput,
Batch,
} from '../types/product-types';
import { Maybe } from '../types/types';
import {
booleanStringField,
isInt,
nullOrNumber,
roundOrDefaultTo,
} from './utils';
import { UserInputError } from 'apollo-server';
import { sendPathChangedCmd } from '../bernard/client';
const MINUTE = 60;
export class ProductAPI extends BaseSQLDataSource {
constructor(config) {
super(config);
}
// async getMaterial(printId: number): Promise<any> {
// TODO: discuss if this is still needed. Anneli wants to remove the choice of standard, premium and self-adhesive
// SELECT materialid, material, (price / 100::float) as price FROM "product-materials"
// }
getWallpaperTypes(row: any): Array<ProductWallpaperType> {
if (!row.wallpapertypes) {
return [];
}
return row.wallpapertypes.map((t: number) => ProductWallpaperType[t]);
}
getProductTypes(row: any): Array<ProductType> {
if (!row.types) {
return [];
}
return row.types.map((type: any) => {
if (type === 'typeillustration') {
return ProductType[ProductType.ILLUSTRATION];
}
if (type === 'typephotography') {
return ProductType[ProductType.PHOTO];
}
return ProductType[ProductType.UNKNOWN];
});
}
getBlacklisting(row: any): Array<ProductBlacklist> {
if (!row.blacklisting) {
return [];
}
return row.blacklisting.map((bl: any) => {
bl.group = ProductGroup[bl.groupId];
return bl;
});
}
getPrintProducts(row: any): Array<PrintProduct> {
if (!row.printproducts) {
return [];
}
return row.printproducts.map((pp: any) => {
pp.group = ProductGroup[pp.groupId];
pp.id = pp.printId;
return pp;
});
}
getProductFields(row: any): ProductFields {
if (!row.fields) {
return null; // 53460 for example
}
// Convert to camelCase first
const fields = Object.keys(row.fields).reduce((mem, key) => {
mem[camelcase(key)] = row.fields[key];
return mem;
}, {});
fields['artNo'] = fields['artNo'] ?? ''; // some actually exists without artNo
fields['name'] = fields['name'] ?? ''; // some actually exists without name
fields['copyright'] = fields['copyright'] ?? '';
fields['comments'] = fields['comments'] ?? '';
fields['batch'] = fields['batch'] ?? '';
fields['height'] = nullOrNumber(fields['height'] ?? null);
fields['width'] = nullOrNumber(fields['width'] ?? null);
fields['printFileWidth'] = nullOrNumber(fields['printFileWidth'] ?? null);
fields['printFileHeight'] = nullOrNumber(fields['printFileHeight'] ?? null);
fields['printFileDpi'] = nullOrNumber(fields['printFileDpi'] ?? null);
fields['marginWidthMax'] = nullOrNumber(fields['marginWidthMax'] ?? null);
fields['marginWidthMin'] = nullOrNumber(fields['marginWidthMin'] ?? null);
fields['marginHeightMax'] = nullOrNumber(fields['marginHeightMax'] ?? null);
fields['marginHeightMin'] = nullOrNumber(fields['marginHeightMin'] ?? null);
fields['focusXpoint'] = nullOrNumber(fields['focusXpoint'] ?? null);
fields['focusYpoint'] = nullOrNumber(fields['focusYpoint'] ?? null);
// Focus points values are in %.
// Should be rounded to two decimals. Is sometimes missing in DB.
// If missing should be treated as 50%, 50%
fields['focusXpoint2'] = roundOrDefaultTo(fields['focusXpoint2'], 2, 50);
fields['focusYpoint2'] = roundOrDefaultTo(fields['focusYpoint2'], 2, 50);
fields['photowallResolution'] = nullOrNumber(
fields['photowallResolution'] ?? null,
);
fields['canvasResolution'] = nullOrNumber(
fields['canvasResolution'] ?? null,
);
fields['wallpaperResolution'] = nullOrNumber(
fields['wallpaperResolution'] ?? null,
);
fields['proportionsWarning'] = booleanStringField(
fields['proportionsWarning'] ?? null,
);
fields['imageResolution'] = nullOrNumber(fields['imageResolution'] ?? null);
return <ProductFields>fields;
}
getOrientation(fields: ProductFields): string {
if (!fields || !fields.width || !fields.height) {
return Orientation[Orientation.UNKNOWN];
}
if (fields.width > fields.height) {
return Orientation[Orientation.LANDSCAPE];
}
if (fields.width < fields.height) {
return Orientation[Orientation.STANDING];
}
return Orientation[Orientation.SQUARE];
}
createProductFromRow(row: any) {
row.blacklisting = this.getBlacklisting(row);
row.printProducts = this.getPrintProducts(row);
row.type = this.getProductTypes(row);
row.wallpaperTypes = this.getWallpaperTypes(row);
row.fields = this.getProductFields(row);
row.designerId = row.designerid;
row.orientation = this.getOrientation(row.fields);
return row;
}
async getProductsTotal(input: ProductsFilterInput): Promise<number> {
const query = sql.productsTotal(input);
const res = await this.cachedRaw(query)
.cache(MINUTE * 60)
.then((data) => data.rows);
return res[0]['count'] as number; // Optimize later to return Promise
}
async getProducts(input: ProductsFilterInput): Promise<Array<Product>> {
// TODO: handle offset
const query = sql.products(input);
return this.knex
.raw(query)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async getProduct(id: number): Promise<Maybe<Product>> {
const query = sql.product(id);
const res = await this.knex.raw(query).then((data) =>
data.rows.map((row) => {
const prod = this.createProductFromRow(row);
return prod;
}),
);
return res.find(Boolean);
}
async getDesignerProducts(designerId: number): Promise<Array<Product>> {
const query = sql.designerProducts();
return this.knex
.raw(query, designerId)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async getCategoryProducts(categoryId: number): Promise<Array<Product>> {
const query = sql.categoryProducts(categoryId);
return this.knex
.raw(query)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async getCategoryWithSubCategoriesProducts(
categoryId: number,
): Promise<Array<Product>> {
const query = sql.categoryWithAllSubCategoriesProducts(categoryId);
return this.knex
.raw(query)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async getKeywordProducts(keywordId: number): Promise<Array<Product>> {
const query = sql.keywordProducts(keywordId);
return this.knex
.raw(query)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async searchByName(name: string): Promise<Array<Product>> {
const query = sql.searchByFieldValue(2); // name
return this.knex
.raw(query, name)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async searchByArtNo(name: string): Promise<Array<Product>> {
const query = sql.searchByFieldValue(1); // artNo
return this.knex
.raw(query, name)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
}
async searchByBatch(name: string): Promise<Array<Batch>> {
const query = sql.searchByFieldValue(36); // batch
const batchRes = await this.knex
.raw(query, name)
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
const batches = {};
batchRes.forEach((p) => {
if (!batches[p.fields.batch]) {
batches[p.fields.batch] = [];
}
batches[p.fields.batch].push(p);
});
return Object.keys(batches).map((key) => {
return <Batch>{
name: key,
products: batches[key],
};
});
}
async getRelatedProducts(id: number): Promise<Array<Product>> {
const ids = await this.knex
.raw(
`SELECT productid2 FROM "product-products_products" WHERE productid1 = ${id}`,
)
.then((data) => data.rows.map((r) => r.productid2));
return ids.map(async (id) => this.getProduct(id));
}
// mutations below
async updateProductField(
productId: number,
field: string,
value: string,
): Promise<void> {
return this.knex.raw(
/* sql */ `
INSERT INTO "product-products_fields" (productid, fieldid, value, inserted, updated)
VALUES (?, (SELECT fieldid FROM "product-fields" WHERE field = ?), ?, now(), now())
ON CONFLICT (productid, fieldid)
DO UPDATE SET "value" = ?`,
[productId, field, value, value],
);
}
async getUniqueProductPath(
path: string,
productId?: number,
): Promise<string> {
// If productId is set then check if path is already
// set on that product.
if (productId) {
const productPath = await this.knex.raw(
`SELECT path FROM "product-products" WHERE productid = ?`,
[productId],
);
if (productPath.rows[0].path === path) {
return path;
}
}
// Check for unique-path
// Get paths from product-products
const basePath = slug(path, { remove: /(\d+)(?!.*\d)$/g }); // Remove last number adf-1-asdf-123 = adf-1-asdf-
const pathResponse = await this.knex.raw(
`SELECT path FROM "product-products" WHERE path LIKE ?`,
[basePath + '%'],
);
const paths = pathResponse.rows.map((item) => item.path);
// Evaluate new pathname
let newPath = '';
// If the exact match of the slug is not listed
// in the paths queried from db. Then use the
// tested slug.
if (!paths.includes(path)) {
newPath = path;
} else {
// Try to find any numbers in the pathname
const numbers = paths
.map((p) => {
const numStr = p.replace(basePath + '-', '');
return isInt(numStr) ? parseInt(numStr) : null;
})
.filter(Boolean); // Remove falsy
if (numbers.length === 0) {
// No numbers found, add 1.
newPath = path + '-1';
} else {
// Get highest number + 1
const next = Math.max(...numbers) + 1;
newPath = basePath + '-' + next;
}
}
return newPath;
}
async addProduct(name: string, batch: string): Promise<number> {
const path = batch ? batch + ' ' + name : name;
const productPath = slug(path);
const safePath = await this.getUniqueProductPath(productPath, null);
const idres = await this.knex
.table('product-products')
.insert({
path: slug(safePath),
browsable: 0,
visible: 0,
})
.returning('productid');
const id = idres[0];
this.updateProductField(id, 'artNo', `e${id}`);
await this.updateProductField(id, 'name', name);
await this.updateProductField(id, 'batch', batch);
return id;
}
async updateProductInfo(
productId: number,
info: ProductInfoInput,
): Promise<any[]> {
// Check path and insert new unique path if taken
const uniquePath = await this.getUniqueProductPath(info.path, productId);
const oldProd = await this.getProduct(productId);
if (oldProd.path !== uniquePath) {
sendPathChangedCmd(oldProd.path, uniquePath);
}
const promises = [];
promises.push(
this.knex
.table('product-products')
.update({
path: uniquePath,
browsable: info.browsable ? 1 : 0,
visible: info.visible ? 1 : 0,
designerid: info.designerId ?? null,
ref1: info.ref1,
ref2: info.ref2,
ref3: info.ref3,
})
.where({ productid: productId }),
this.updateProductField(productId, 'artNo', info.artNo),
this.updateProductField(productId, 'name', info.name),
this.updateProductField(productId, 'batch', info.batch),
this.updateProductField(productId, 'copyright', info.copyright),
this.updateProductField(
productId,
'print_file_width',
info.printFileWidth.toString(),
),
this.updateProductField(
productId,
'print_file_height',
info.printFileHeight.toString(),
),
this.updateProductField(
productId,
'print_file_dpi',
info.printFileDpi.toString(),
),
);
return Promise.all(promises);
}
async updateFocusPoint(
productId: number,
focusXpoint2: number,
focusYpoint2: number,
): Promise<Promise<void>[]> {
const promises = [];
promises.push(
this.updateProductField(
productId,
'focusXpoint2',
focusXpoint2.toString(),
),
this.updateProductField(
productId,
'focusYpoint2',
focusYpoint2.toString(),
),
);
return Promise.all(promises);
}
async updateComments(productId: number, comments: string): Promise<void> {
return this.updateProductField(productId, 'comments', comments);
}
async updateBlacklisting(
productId: number,
markets: Array<ProductBlacklistMarketInput>,
) {
const sql = /* sql */ `
INSERT INTO product_blacklist (product_id, group_id, market_id, created_at, updated_at)
VALUES (?, ?, ?, now(), now())
ON CONFLICT (product_id, group_id, market_id)
DO NOTHING`;
await this.knex.raw(
/* sql */ `
DELETE FROM product_blacklist WHERE product_id = ?
`,
[productId],
);
const insertsPromises = [];
markets.forEach((market) => {
market.groupIds.forEach((groupId) => {
const query = this.knex.raw(sql, [productId, groupId, market.marketId]);
insertsPromises.push(query);
});
});
return Promise.all(insertsPromises);
}
async setWallpaperTypes(
productId: number,
wallpaperTypes: Array<ProductWallpaperType>,
): Promise<any[]> {
await this.knex('product_wallpapertypes')
.where('product_id', productId)
.del();
const promises = wallpaperTypes.map((wpt) => {
const typeId = ProductWallpaperType[wpt];
return this.knex('product_wallpapertypes').insert({
product_id: productId,
wallpapertype_id: typeId,
});
});
return Promise.all(promises);
}
async updateGroups(productId: number, groupIds: number[]): Promise<any[]> {
// Fetch the current state
const allPrintProducts = await this.knex.raw(
`SELECT printid, groupid FROM "product-printproducts" WHERE productid = ?`,
[productId],
);
const currentGroups = allPrintProducts.rows.map((item) => item.groupid);
// What we should add
const shouldAdd = groupIds.filter((group) => {
return !currentGroups.includes(group);
});
// What we should delete
const shouldDelete = allPrintProducts.rows.filter((item) => {
return !groupIds.includes(item.groupid);
});
// DELETE
if (shouldDelete.length > 0) {
// Delete from product-printproducts_materials
const printProductIds = shouldDelete.map((item) => item.printid);
// Delete from materials first, must be in sequence since we have constraint in materials to printproducts
await this.knex
.from('product-printproducts_materials')
.whereIn('printid', printProductIds)
.del();
// Delete from product-printproducts
await this.knex
.from('product-printproducts')
.whereIn('printid', printProductIds)
.del();
}
// INSERT
if (shouldAdd.length > 0) {
const insertionProducts = [];
// Insert in product-printproducts
// TODO: Depracate typeid, its not used to check repeating
const insertQuery = `INSERT INTO "product-printproducts" (productid, groupid, typeid) VALUES (?, ?, ?)`;
shouldAdd.forEach((groupId) => {
insertionProducts.push(
this.knex.raw(insertQuery, [
productId,
groupId,
groupId === ProductGroup.WALLPAPER
? ProductSizeTypes.TILING
: ProductSizeTypes.SCALING,
]),
);
});
// Run parallell
await Promise.all(insertionProducts);
// Fetch the new printids to be able to insert new materials
const newPrintIds = await this.knex
.from('product-printproducts')
.select('printid', 'groupid')
.where('productid', productId)
.whereIn('groupid', shouldAdd);
// Insert printIds into product-printproducts_materials
const insertMaterials = [];
const insertMaterialQuery = `INSERT INTO "product-printproducts_materials" (printid, materialid) VALUES (?, ?)`;
newPrintIds.forEach(({ printid, groupid }) => {
switch (groupid) {
case ProductGroup.PHOTO_WALLPAPER:
case ProductGroup.WALLPAPER:
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.STANDARD_WALLPAPER,
]),
);
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.SELF_ADHESIVE_WALLPAPER,
]),
);
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.PREMIUM_WALLPAPER,
]),
);
break;
case ProductGroup.CANVAS:
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.STANDARD_CANVAS,
]),
);
break;
case ProductGroup.POSTER:
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.PREMIUM_POSTER,
]),
);
break;
case ProductGroup.FRAMED_PRINT:
insertMaterials.push(
this.knex.raw(insertMaterialQuery, [
printid,
ProductMaterials.PREMIUM_FRAMED_PRINT,
]),
);
break;
}
});
return Promise.all(insertMaterials);
}
}
/**
* @func productGroupInteriors
*
* @param productId The product id for the targeted product
* @param groupId The targeted group id, if product does not have group it will be created
* @param uris The uris for the interiors, common for rooms and own uploades
*/
async addInteriorsToProductGroup(
productId: number,
groupId: number,
uris: string[],
): Promise<Promise<void>[]> {
// Create object to hold data as we go along all the db calls
// This object needs to support new own uploaded images, these should have
// some other folder prefix, like /interiors-buffer/. These will
// be inserted into DB first and then the image on s3 will be
// moved to /interior-images/ with the correct id.
const insertRegistry = uris.map((uri, index) => {
let id = null;
let roomName = null;
if (uri.indexOf('/') !== 0) {
uri = '/' + uri;
}
if (uri.indexOf('/interiors/') > -1) {
const spl = uri.split('/');
roomName = `${spl[5].split('.')[0]}_${spl[4]}_${spl[3]}`;
}
if (uri.indexOf('/interior-images/') > -1) {
id = parseInt(uri.split('/')[2].split('.')[0]);
}
return {
id: id,
uri: uri,
roomName: roomName || null,
roomId: null,
position: index,
};
});
// Get all the printproducts from the productId defined.
// This is needed for the insert and for the check if a product
// has the defined group or not.
const allPrintProducts = await this.knex.raw(
`SELECT printid, groupid FROM "product-printproducts" WHERE productid = ?`,
[productId],
);
// Predefine the print id needed for insert.
let insertPrintId;
// Map out the group ids.
const currentGroupIds = allPrintProducts.rows.map(({ groupid }) => groupid);
// This product does not have group with groupId
if (!currentGroupIds.includes(groupId)) {
await this.updateGroups(productId, currentGroupIds.concat([groupId]));
// Get the new printId
const printIdsResponse = await this.knex.raw(
`SELECT printid FROM "product-printproducts" WHERE productid = ? AND groupid = ?`,
[productId, groupId],
);
insertPrintId = printIdsResponse.rows.map(({ printid }) => printid)[0];
} else {
// This product had the targeted groupid, then we use
// the previous query for printId to save some DB calls.
insertPrintId = allPrintProducts.rows
.map(({ printid, groupid }) => {
if (groupid === groupId) {
return printid;
}
})
.filter(Boolean)[0]; // Remove falsy
}
// Delete all prior interiors
await this.knex.raw(`DELETE FROM interiors WHERE print_id = ?`, [
insertPrintId,
]);
// Insert room-ids, printids and position into "interiors" table.
const insertPromises = [];
insertRegistry.forEach((item) => {
if (item.roomName) {
insertPromises.push(
this.knex.raw(
`INSERT INTO interiors (print_id, room_id, position) VALUES (?, (SELECT id FROM rooms WHERE name = ?), ?)`,
[insertPrintId, item.roomName, item.position],
),
);
} else {
insertPromises.push(
this.knex.raw(
`INSERT INTO interiors (id, print_id, position) VALUES (?, ?, ?)`,
[item.id, insertPrintId, item.position],
),
);
}
});
return Promise.all(insertPromises);
}
async setProportionsWarning(
productId: number,
proportions: ProductProportionsInput,
): Promise<Promise<void>[]> {
const promises = [];
promises.push(
this.updateProductField(
productId,
'proportions_warning',
proportions.proportionsWarning ? '1' : '0',
),
this.updateProductField(
productId,
'margin_width_max',
proportions.marginWidthMax.toString(),
),
this.updateProductField(
productId,
'margin_width_min',
proportions.marginWidthMin.toString(),
),
this.updateProductField(
productId,
'margin_height_max',
proportions.marginHeightMax.toString(),
),
this.updateProductField(
productId,
'margin_height_min',
proportions.marginHeightMin.toString(),
),
);
return Promise.all(promises);
}
async addRelatedProducts(
productId: number,
articleNumbers: Array<string>,
): Promise<Promise<void>[]> {
const whereClause = articleNumbers.map((a) => `'${a}'`).join(',');
const query = /* sql */ `
SELECT pp.productid, ppf.value FROM "product-products" pp
LEFT JOIN "product-products_fields" ppf
ON ppf.productid = pp.productid AND ppf.fieldid = 1
WHERE ppf.value IN (${whereClause});
`;
const res = await this.cachedRaw(query)
.cache(MINUTE)
.then((data) => data.rows);
if (!res.length) {
throw new UserInputError('No articles found');
}
const fieldsToInsert = res.map((item) => ({
productid1: productId,
productid2: item.productid,
}));
// Add this productId as related to its friends
res.forEach((item) => {
fieldsToInsert.push({
productid1: item.productid,
productid2: productId,
});
});
return this.knex.raw(`? ON CONFLICT (productid1, productid2) DO NOTHING;`, [
this.knex('product-products_products').insert(fieldsToInsert),
]);
}
/**
* Removes variant products from productid as well as the sent in
* productid from its variants
*/
async removeRelatedProducts(
productId,
relatedProductIds,
): Promise<Promise<void>[]> {
// remove connection between the friends id and this produdId
const promises = relatedProductIds.map((pId) => {
return this.knex('product-products_products')
.where('productid1', pId)
.where('productid2', productId)
.del();
});
// remove connection to friends for this productsid
const query = this.knex('product-products_products')
.where('productid1', productId)
.whereIn('productid2', relatedProductIds)
.del();
promises.push(query);
return Promise.all(promises);
}
async addCategoriesToProduct(
productId,
categoryIds,
): Promise<Promise<void>[]> {
const promises = categoryIds.map((categoryId) =>
this.knex('product_category').insert({
product_id: productId,
category_id: categoryId,
}),
);
return Promise.all(promises);
}
async removeCategoriesFromProduct(
productId,
categoryIds,
): Promise<Promise<void>[]> {
const promises = categoryIds.map((categoryId) =>
this.knex('product_category')
.where({
product_id: productId,
category_id: categoryId,
})
.del(),
);
return Promise.all(promises);
}
}