Added product admin features
This commit is contained in:
@@ -140,7 +140,7 @@ describe('GraphQL Apollo tests', () => {
|
||||
it('should be possible to fetch a designers with limit', async () => {
|
||||
// CONTROL
|
||||
const db_response = await allContainers.db.raw(
|
||||
`SELECT * FROM designers LIMIT 3;`,
|
||||
`SELECT * FROM designers ORDER BY name LIMIT 3;`,
|
||||
);
|
||||
const controlData = db_response.rows;
|
||||
// -------------------
|
||||
@@ -186,10 +186,6 @@ describe('GraphQL Apollo tests', () => {
|
||||
|
||||
expect(designers.length).toBe(3);
|
||||
|
||||
expect(designers[0].id).toBe('1');
|
||||
expect(designers[1].id).toBe('2');
|
||||
expect(designers[2].id).toBe('3');
|
||||
|
||||
expect(designers[0].name).toBe(controlData[0].name);
|
||||
expect(designers[1].name).toBe(controlData[1].name);
|
||||
expect(designers[2].name).toBe(controlData[2].name);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { RESTDataSource } from 'apollo-datasource-rest';
|
||||
|
||||
export class Api1DataSource extends RESTDataSource {
|
||||
authHeader: string;
|
||||
constructor() {
|
||||
super();
|
||||
this.baseURL = 'http://docker.for.mac.localhost:8082';
|
||||
this.authHeader = 'Basic ZGV2OmRldg==';
|
||||
}
|
||||
|
||||
willSendRequest(request) {
|
||||
request.headers.set('Authorization', this.authHeader);
|
||||
}
|
||||
|
||||
async getProduct(id) {
|
||||
return await this.get(`/products/${id}?` + new URLSearchParams({}), null, {
|
||||
cacheOptions: { ttl: 5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { RESTDataSource } from 'apollo-datasource-rest';
|
||||
|
||||
/*
|
||||
https://wE4D45790.api.esales.apptus.cloud/api/v2/panels/category
|
||||
?esales.market=SE&
|
||||
esales.customerKey=471c09ac-a775-4dd2-8bfc-041bfbb6d7b7&
|
||||
esales.sessionKey=b106299c-eac0-4f6f-96d2-93e57c255784&
|
||||
window_first=1&
|
||||
window_last=10&
|
||||
selected_category=categories_SE:'root/animals'&
|
||||
root_category=categories_SE:'root'
|
||||
&max_facets=50
|
||||
&filter=blacklisted:'0' AND visible_in_listing:'1' AND visible_in_shop:'1' AND (group:'poster' OR group:'framed-print')
|
||||
blacklisted:'0'%20AND%20visible_in_listing:'1'%20AND%20visible_in_shop:'1'%20AND%20(group:'poster'%20OR%20group:'framed-print')
|
||||
|
||||
*/
|
||||
export class Api2DataSource extends RESTDataSource {
|
||||
authHeader: string;
|
||||
constructor() {
|
||||
super();
|
||||
this.baseURL = 'https://api-staging.photowall.com/';
|
||||
this.authHeader =
|
||||
'Basic ZGExNzU5ZWEtOTBiNC00OTNjLWJjYWItNjAwNDg1NjA5YjkyOg==';
|
||||
}
|
||||
|
||||
willSendRequest(request) {
|
||||
request.headers.set('Authorization', this.authHeader);
|
||||
}
|
||||
|
||||
async getPrice(market, width, height, sku) {
|
||||
return await this.get(
|
||||
'prices/wallpaper?' +
|
||||
new URLSearchParams({
|
||||
width: '200',
|
||||
height: '200',
|
||||
market: '1',
|
||||
product: '58941',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ WHERE product_category.product_id = ?
|
||||
data.rows.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
path: row.path.replace(/^root/, ''),
|
||||
pathNumeric: row.path_numeric,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -29,33 +29,16 @@ WHERE product_category.product_id = ?
|
||||
}
|
||||
|
||||
async getCategories(): Promise<Array<Category>> {
|
||||
return await this.knex
|
||||
.select('*')
|
||||
.from('v_categorytree')
|
||||
.cache(MINUTE)
|
||||
.then((rows) => {
|
||||
return rows.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
path: row.path.replace(/^root/, ''),
|
||||
};
|
||||
});
|
||||
});
|
||||
return this.knex.select('*').from('v_categorytree').cache(MINUTE);
|
||||
}
|
||||
|
||||
async getCategoryById(id: number): Promise<Category> {
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('v_categorytree')
|
||||
.where('id', id)
|
||||
.first()
|
||||
.cache(MINUTE)
|
||||
.then((row) => {
|
||||
return {
|
||||
...row,
|
||||
path: row.path.replace(/^root/, ''),
|
||||
};
|
||||
});
|
||||
.cache(MINUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,10 +48,18 @@ WHERE product_category.product_id = ?
|
||||
SELECT category_keyword.category_id, keywords.value
|
||||
FROM category_keyword
|
||||
JOIN keywords ON category_keyword.keyword_id = keywords.id;
|
||||
|
||||
|
||||
FROM categorydata cd
|
||||
JOIN categorydatakeys cdk ON cd.datakey_id = cdk.id
|
||||
*
|
||||
* category texts
|
||||
SELECT name,
|
||||
text
|
||||
FROM categorydata cd
|
||||
JOIN categorydatakeys cdk ON cd.datakey_id = cdk.id
|
||||
WHERE category_id = ?
|
||||
AND locale_id = ?;
|
||||
|
||||
|
||||
* category teaser images???
|
||||
*/
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ export class DesignerAPI extends BaseSQLDataSource {
|
||||
}
|
||||
|
||||
async getDesignerById(id: number): Promise<Designer> {
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('designers')
|
||||
.where('designerid', id)
|
||||
@@ -24,11 +24,12 @@ export class DesignerAPI extends BaseSQLDataSource {
|
||||
}
|
||||
|
||||
async getDesigners(limit: Maybe<number>): Promise<Array<Designer>> {
|
||||
limit = limit ?? 100;
|
||||
return await this.knex
|
||||
limit = limit ?? 5000;
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('designers')
|
||||
.limit(limit)
|
||||
.orderBy('name')
|
||||
.cache(MINUTE)
|
||||
.then((rows) => {
|
||||
return rows.map((row) => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { RESTDataSource } from 'apollo-datasource-rest';
|
||||
import { InteriorType } from '../types/interior-types';
|
||||
import { Orientation } from '../types/product-types';
|
||||
|
||||
export class ImageServerApi extends RESTDataSource {
|
||||
constructor() {
|
||||
super();
|
||||
this.baseURL =
|
||||
process.env.ENVIRONMENT_NAME === 'production'
|
||||
? 'http://images.photowall.com'
|
||||
: 'http://images-dev.photowall.com';
|
||||
}
|
||||
|
||||
willSendRequest(request) {
|
||||
request.headers.set('X-Photowall', 'pele');
|
||||
}
|
||||
|
||||
async getInteriorsForProduct(
|
||||
productId: number,
|
||||
type: InteriorType,
|
||||
orientation: Orientation,
|
||||
): Promise<Array<{ uri: string }>> {
|
||||
const it = type.toString().toLowerCase();
|
||||
const or = orientation.toString().toLowerCase();
|
||||
return this.get(`/rooms/${productId}/${or}/${it}/`, null, {
|
||||
cacheOptions: { ttl: 5 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Interior,
|
||||
InteriorsFilter,
|
||||
InteriorType,
|
||||
} from '../types/interior-types';
|
||||
import { Orientation } from '../types/product-types';
|
||||
import { Maybe } from '../types/types';
|
||||
import { BaseSQLDataSource } from './BaseSQLDataSource';
|
||||
import { ImageServerApi } from './imageserver-api';
|
||||
import { convertToPossibleType } from './utils';
|
||||
|
||||
const MINUTE = 60;
|
||||
|
||||
export class InteriorAPI extends BaseSQLDataSource {
|
||||
imageServerApi: ImageServerApi;
|
||||
constructor(config, imageServerApi: ImageServerApi) {
|
||||
super(config);
|
||||
this.imageServerApi = imageServerApi;
|
||||
}
|
||||
|
||||
getInteriorType(roomTypePart: string): string {
|
||||
switch (roomTypePart) {
|
||||
case 'wallpaper':
|
||||
return InteriorType[InteriorType.WALLPAPER];
|
||||
case 'painting':
|
||||
return InteriorType[InteriorType.PAINTING];
|
||||
case 'poster':
|
||||
return InteriorType[InteriorType.POSTER];
|
||||
case 'framed-print':
|
||||
return InteriorType[InteriorType.FRAMED_PRINT];
|
||||
default:
|
||||
throw new Error(`Unknown room type: ${roomTypePart}`);
|
||||
}
|
||||
}
|
||||
|
||||
getInteriorOrientation(roomOrientationPart: string): string {
|
||||
switch (roomOrientationPart) {
|
||||
case 'landscape':
|
||||
return Orientation[Orientation.LANDSCAPE];
|
||||
case 'standing':
|
||||
return Orientation[Orientation.PORTRAIT];
|
||||
case 'square':
|
||||
return Orientation[Orientation.SQUARE];
|
||||
default:
|
||||
throw new Error(`Unknown room orientation: ${roomOrientationPart}`);
|
||||
}
|
||||
}
|
||||
|
||||
getInteriorRoomNumber(roomNumberPart: string): number {
|
||||
return convertToPossibleType(roomNumberPart.slice(4));
|
||||
}
|
||||
|
||||
decorateRow(row: any): Interior {
|
||||
const parts = row.roomName.split('_');
|
||||
row.roomNumber = this.getInteriorRoomNumber(parts[0]);
|
||||
row.type = this.getInteriorType(parts[1]);
|
||||
row.orientation = this.getInteriorOrientation(parts[2]);
|
||||
return row;
|
||||
}
|
||||
|
||||
async getInteriors(filter: Maybe<InteriorsFilter>): Promise<Array<Interior>> {
|
||||
const sql = /* sql */ `
|
||||
SELECT rooms.*, room_types.name roomType FROM rooms
|
||||
LEFT JOIN room_types ON rooms.room_type_id = room_types.id
|
||||
`;
|
||||
|
||||
const allInteriors = await this.cachedRaw(sql)
|
||||
.cache(MINUTE * 60)
|
||||
.then((data) =>
|
||||
data.rows.map((row) => {
|
||||
const res = {
|
||||
...row,
|
||||
roomName: row.name,
|
||||
roomType: row.roomtype,
|
||||
};
|
||||
this.decorateRow(res);
|
||||
return res;
|
||||
}),
|
||||
);
|
||||
|
||||
// Based on filter get valid interiors and filter out any else from all interiors
|
||||
if (filter) {
|
||||
const validUris = await this.imageServerApi.getInteriorsForProduct(
|
||||
filter.productId,
|
||||
filter.type,
|
||||
filter.orientation,
|
||||
);
|
||||
return allInteriors.filter((i: Interior) => {
|
||||
const it = i.type.toString().toLowerCase();
|
||||
const or = i.orientation.toString().toLocaleLowerCase();
|
||||
const uriMatch = `${or}/${it}/room${i.roomNumber}.`;
|
||||
const foundUri = validUris.find(({ uri }) => uri.includes(uriMatch));
|
||||
if (foundUri) {
|
||||
i.uri = foundUri.uri;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
// No filter so return all
|
||||
return allInteriors;
|
||||
}
|
||||
|
||||
async getPrintProductInteriors(printId: number): Promise<Array<Interior>> {
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('v_interior_image_urls')
|
||||
.orderBy('position')
|
||||
.where('print_id', printId)
|
||||
.cache(MINUTE)
|
||||
.then((rows) => {
|
||||
return rows.map((row) => {
|
||||
const res = {
|
||||
...row,
|
||||
id: row.roomId,
|
||||
};
|
||||
this.decorateRow(res);
|
||||
return res;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,9 @@ WHERE product_keyword.product_id = ?
|
||||
ORDER BY keywords.value
|
||||
`;
|
||||
|
||||
const res = await this.knex
|
||||
return this.knex
|
||||
.raw(query, productId)
|
||||
.then((data) => data.rows.map((row) => this.getType(row)));
|
||||
return res;
|
||||
}
|
||||
|
||||
async getKeywords(): Promise<Array<Keyword>> {
|
||||
@@ -40,14 +39,13 @@ SELECT keywords.*, keyword_type.type_id FROM keywords
|
||||
LEFT JOIN keyword_type ON keyword_type.keyword_id = keywords.id
|
||||
ORDER BY keywords.value
|
||||
`;
|
||||
const res = await this.knex
|
||||
return this.knex
|
||||
.raw(query)
|
||||
.then((data) => data.rows.map((row) => this.getType(row)));
|
||||
return res;
|
||||
}
|
||||
|
||||
async getKeywordById(id: number): Promise<Keyword> {
|
||||
const res = await this.knex
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('keywords')
|
||||
.leftJoin('keyword_type', 'keyword_type.keyword_id', 'keywords.id')
|
||||
@@ -55,7 +53,6 @@ ORDER BY keywords.value
|
||||
.first()
|
||||
.cache(MINUTE)
|
||||
.then((row) => this.getType(row));
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { SQLDataSource } from 'datasource-sql';
|
||||
import { Market } from '../types/market-types';
|
||||
|
||||
const MINUTE = 60;
|
||||
|
||||
export class MarketAPI extends SQLDataSource {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
}
|
||||
|
||||
async getMarketById(id: number): Promise<Market> {
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('markets')
|
||||
.where('id', id)
|
||||
.first()
|
||||
.cache(MINUTE * 5);
|
||||
}
|
||||
|
||||
async getMarketByName(name: string): Promise<Market> {
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('markets')
|
||||
.where('name', name)
|
||||
.first()
|
||||
.cache(MINUTE * 5);
|
||||
}
|
||||
|
||||
async getMarkets(): Promise<Array<Market>> {
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('markets')
|
||||
.cache(MINUTE * 5);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { camelcase } from 'stringcase';
|
||||
import {
|
||||
Address,
|
||||
ContactInformation,
|
||||
Market,
|
||||
Order,
|
||||
OrderRow,
|
||||
} from '../types/order-types';
|
||||
@@ -17,24 +16,6 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
super(config);
|
||||
}
|
||||
|
||||
async getMarketById(id: number): Promise<Market> {
|
||||
return await this.knex
|
||||
.select('*')
|
||||
.from('markets')
|
||||
.where('id', id)
|
||||
.first()
|
||||
.cache(MINUTE);
|
||||
}
|
||||
|
||||
async getMarketByName(name: string): Promise<Market> {
|
||||
return await this.knex
|
||||
.select('*')
|
||||
.from('markets')
|
||||
.where('name', name)
|
||||
.first()
|
||||
.cache(MINUTE);
|
||||
}
|
||||
|
||||
async getAddress(addressId: number): Promise<Maybe<Address>> {
|
||||
const row = await this.knex
|
||||
.select('*')
|
||||
@@ -80,20 +61,13 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
return row;
|
||||
}
|
||||
|
||||
async getOrdersTotal(input: Maybe<GeneralInput>): Promise<Number> {
|
||||
let query = this.knex.table('orders');
|
||||
|
||||
if (input?.dates?.from) {
|
||||
query = query.where('inserted', '>=', input.dates.from);
|
||||
}
|
||||
|
||||
if (input?.dates?.to) {
|
||||
query = query.where('inserted', '<=', input.dates.to);
|
||||
}
|
||||
|
||||
const res = await query.clone().count();
|
||||
console.log(res);
|
||||
return 57777;
|
||||
async getOrdersTotal(input: Maybe<GeneralInput>): Promise<number> {
|
||||
const res = await this.getOrdersQuery(input)
|
||||
.clone()
|
||||
.count()
|
||||
.first()
|
||||
.cache(MINUTE * 60);
|
||||
return convertToPossibleType(res['count']); // Optimize later to return Promise
|
||||
}
|
||||
|
||||
async getOrders(input: Maybe<GeneralInput>): Promise<Array<Order>> {
|
||||
@@ -103,7 +77,7 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
query = query.limit(input?.limit);
|
||||
}
|
||||
query = query.orderBy('inserted', 'ASC');
|
||||
return await query
|
||||
return query
|
||||
.cache(MINUTE)
|
||||
.then((rows) => rows.map((row) => this.createOrderFromRow(row)));
|
||||
}
|
||||
@@ -122,7 +96,7 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
}
|
||||
|
||||
async getOrderById(id: number): Promise<Order> {
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.select('*')
|
||||
.from('orders')
|
||||
.where('id', id)
|
||||
@@ -200,7 +174,7 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
.raw('SELECT * FROM "order-rows" WHERE orderid = ?', orderId)
|
||||
.then((data) => data.rows);
|
||||
|
||||
return await this.getOrderRows(rows);
|
||||
return this.getOrderRows(rows);
|
||||
}
|
||||
|
||||
async getOrderRowsByDesignerId(
|
||||
@@ -228,6 +202,6 @@ export class OrderAPI extends BaseSQLDataSource {
|
||||
const rows = await this.knex
|
||||
.raw(query, [designerId, this.getSQLDate(from), this.getSQLDate(to)])
|
||||
.then((data) => data.rows);
|
||||
return await this.getOrderRows(rows);
|
||||
return this.getOrderRows(rows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SQLDataSource } from 'datasource-sql';
|
||||
import { BaseSQLDataSource } from './BaseSQLDataSource';
|
||||
import { camelcase } from 'stringcase';
|
||||
import * as sql from './sql';
|
||||
import {
|
||||
Product,
|
||||
@@ -6,12 +7,16 @@ import {
|
||||
PrintProduct,
|
||||
ProductType,
|
||||
ProductBlacklist,
|
||||
ProductWallpaperType,
|
||||
ProductFields,
|
||||
Orientation,
|
||||
} from '../types/product-types';
|
||||
import { Maybe } from '../types/types';
|
||||
import { convertToPossibleType } from './utils';
|
||||
|
||||
const MINUTE = 60;
|
||||
|
||||
export class ProductAPI extends SQLDataSource {
|
||||
export class ProductAPI extends BaseSQLDataSource {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
}
|
||||
@@ -21,6 +26,25 @@ export class ProductAPI extends SQLDataSource {
|
||||
// SELECT materialid, material, (price / 100::float) as price FROM "product-materials"
|
||||
// }
|
||||
|
||||
getWallpaperTypes(row: any): Array<ProductWallpaperType> {
|
||||
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 [];
|
||||
@@ -42,26 +66,36 @@ export class ProductAPI extends SQLDataSource {
|
||||
});
|
||||
}
|
||||
|
||||
getProductTypes(row: any): Array<ProductType> {
|
||||
if (!row.types) {
|
||||
return [];
|
||||
getProductFields(row: any): ProductFields {
|
||||
const fields = Object.keys(row.fields).reduce((mem, key) => {
|
||||
mem[camelcase(key)] = convertToPossibleType(row.fields[key]);
|
||||
return mem;
|
||||
}, {});
|
||||
|
||||
return <ProductFields>fields;
|
||||
}
|
||||
|
||||
getOrientation(fields: ProductFields): string {
|
||||
if (!fields.width || !fields.height) {
|
||||
return Orientation[Orientation.UNKNOWN];
|
||||
}
|
||||
return row.types.map((type: any) => {
|
||||
if (type === 'typeillustration') {
|
||||
return ProductType[ProductType.ILLUSTRATION];
|
||||
}
|
||||
if (type === 'typephotography') {
|
||||
return ProductType[ProductType.PHOTO];
|
||||
}
|
||||
return ProductType[ProductType.UNKNOWN];
|
||||
});
|
||||
if (fields.width > fields.height) {
|
||||
return Orientation[Orientation.LANDSCAPE];
|
||||
}
|
||||
if (fields.width < fields.height) {
|
||||
return Orientation[Orientation.PORTRAIT];
|
||||
}
|
||||
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);
|
||||
row.fields_json = row.fields;
|
||||
return row;
|
||||
}
|
||||
@@ -74,7 +108,7 @@ export class ProductAPI extends SQLDataSource {
|
||||
limit = limit ?? 100;
|
||||
const query = sql.products(limit, visible, browsable);
|
||||
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.raw(query)
|
||||
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
|
||||
}
|
||||
@@ -94,7 +128,7 @@ export class ProductAPI extends SQLDataSource {
|
||||
async getCategoryProducts(categoryId: number): Promise<Array<Product>> {
|
||||
const query = sql.categoryProducts(categoryId);
|
||||
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.raw(query)
|
||||
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
|
||||
}
|
||||
@@ -102,8 +136,18 @@ export class ProductAPI extends SQLDataSource {
|
||||
async getKeywordProducts(keywordId: number): Promise<Array<Product>> {
|
||||
const query = sql.keywordProducts(keywordId);
|
||||
|
||||
return await this.knex
|
||||
return this.knex
|
||||
.raw(query)
|
||||
.then((data) => data.rows.map((row) => this.createProductFromRow(row)));
|
||||
}
|
||||
|
||||
async getRelatedProducts(id: number): Promise<Array<Product>> {
|
||||
const ids = await this.cachedRaw(
|
||||
`SELECT productid2 FROM "product-products_products" WHERE productid1 = ${id}`,
|
||||
)
|
||||
.cache(MINUTE * 60)
|
||||
.then((data) => data.rows.map((r) => r.productid2));
|
||||
|
||||
return ids.map(async (id) => this.getProduct(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,57 +4,75 @@ import { Maybe } from '../../types/types';
|
||||
|
||||
const baseQuery = /* sql */ `
|
||||
|
||||
SELECT products.productid as id,
|
||||
SELECT products.productid AS id,
|
||||
stock.stockid,
|
||||
products.path,
|
||||
products.visible,
|
||||
products.browsable,
|
||||
products.designerid,
|
||||
products.inserted,
|
||||
products.updated,
|
||||
products.ref1,
|
||||
products.ref2,
|
||||
products.ref3,
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'printId',
|
||||
"product-printproducts".printid,
|
||||
'productId',
|
||||
"product-printproducts".productid,
|
||||
'groupId',
|
||||
"product-printproducts".groupid,
|
||||
'inserted',
|
||||
"product-printproducts".inserted,
|
||||
'updated',
|
||||
"product-printproducts".updated
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'printId',
|
||||
"product-printproducts".printid,
|
||||
'productId',
|
||||
"product-printproducts".productid,
|
||||
'groupId',
|
||||
"product-printproducts".groupid,
|
||||
'inserted',
|
||||
"product-printproducts".inserted,
|
||||
'updated',
|
||||
"product-printproducts".updated
|
||||
)
|
||||
)
|
||||
) FROM "product-printproducts" WHERE "product-printproducts".productid = products.productid
|
||||
FROM "product-printproducts"
|
||||
WHERE "product-printproducts".productid = products.productid
|
||||
) AS printproducts,
|
||||
( SELECT json_agg(
|
||||
json_build_object(
|
||||
'id',
|
||||
product_blacklist.id,
|
||||
'productId',
|
||||
product_blacklist.product_id,
|
||||
'marketId',
|
||||
product_blacklist.market_id,
|
||||
'groupId',
|
||||
product_blacklist.group_id,
|
||||
'inserted',
|
||||
product_blacklist.created_at,
|
||||
'updated',
|
||||
product_blacklist.updated_at
|
||||
(
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'id',
|
||||
product_blacklist.id,
|
||||
'productId',
|
||||
product_blacklist.product_id,
|
||||
'marketId',
|
||||
product_blacklist.market_id,
|
||||
'groupId',
|
||||
product_blacklist.group_id,
|
||||
'inserted',
|
||||
product_blacklist.created_at,
|
||||
'updated',
|
||||
product_blacklist.updated_at
|
||||
)
|
||||
)
|
||||
) FROM product_blacklist WHERE product_blacklist.product_id = products.productid
|
||||
FROM product_blacklist
|
||||
WHERE product_blacklist.product_id = products.productid
|
||||
) AS blacklisting,
|
||||
( SELECT json_object_agg(fields.field, pf.value)
|
||||
(
|
||||
SELECT json_object_agg(fields.field, pf.value)
|
||||
FROM "product-products_fields" pf
|
||||
JOIN "product-fields" fields ON fields.fieldid = pf.fieldid
|
||||
JOIN "product-fields" fields ON fields.fieldid = pf.fieldid
|
||||
WHERE pf.productid = products.productid
|
||||
) AS fields,
|
||||
( SELECT json_agg(keywords.value)
|
||||
(
|
||||
SELECT json_agg(keywords.value)
|
||||
FROM product_keyword pk
|
||||
JOIN keywords ON keywords.id = pk.keyword_id
|
||||
WHERE keywords.id IN (1103,1104)AND pk.product_id = products.productid
|
||||
) AS types
|
||||
FROM "product-products" products
|
||||
JOIN keywords ON keywords.id = pk.keyword_id
|
||||
WHERE keywords.id IN (1103, 1104)
|
||||
AND pk.product_id = products.productid
|
||||
) AS types,
|
||||
(
|
||||
SELECT json_agg(wt.wallpapertype_id)
|
||||
FROM product_wallpapertypes wt
|
||||
WHERE wt.product_id = products.productid
|
||||
) AS wallpapertypes
|
||||
FROM "product-products" products
|
||||
LEFT JOIN "product-stockproducts" stock ON stock.productid = products.productid
|
||||
`;
|
||||
|
||||
export function products(
|
||||
|
||||
+14
-12
@@ -9,33 +9,34 @@ import resolvers from './resolvers';
|
||||
import { OrderAPI } from './datasources/order-api';
|
||||
import { ProductAPI } from './datasources/product-api';
|
||||
import { DesignerAPI } from './datasources/designer-api';
|
||||
import { Api2DataSource } from './datasources/api2-datasource';
|
||||
import { Api1DataSource } from './datasources/api1-datasource';
|
||||
|
||||
import { verifyToken } from './cognito/cognito-client';
|
||||
import { CategoryAPI } from './datasources/category-api';
|
||||
import { KeywordAPI } from './datasources/keyword-api';
|
||||
import { MarketAPI } from './datasources/market-api';
|
||||
import { ImageServerApi } from './datasources/imageserver-api';
|
||||
import { InteriorAPI } from './datasources/interior-api';
|
||||
|
||||
console.log(`dbConfig`, dbConfig);
|
||||
import { verifyToken } from './cognito/cognito-client';
|
||||
|
||||
// Should we convert columns?
|
||||
const knexConfig = knexStringcase(dbConfig);
|
||||
|
||||
// set up any dataSources our resolvers need
|
||||
const api1 = new Api1DataSource();
|
||||
const api2 = new Api2DataSource();
|
||||
const categoryApi = new CategoryAPI(knexConfig);
|
||||
const designerApi = new DesignerAPI(knexConfig);
|
||||
const imageServerApi = new ImageServerApi();
|
||||
const interiorApi = new InteriorAPI(knexConfig, imageServerApi);
|
||||
const keywordApi = new KeywordAPI(knexConfig);
|
||||
const marketApi = new MarketAPI(knexConfig);
|
||||
const orderApi = new OrderAPI(knexConfig);
|
||||
const productApi = new ProductAPI(knexConfig);
|
||||
const categoryApi = new CategoryAPI(knexConfig);
|
||||
const keywordApi = new KeywordAPI(knexConfig);
|
||||
const designerApi = new DesignerAPI(knexConfig);
|
||||
|
||||
const dataSources = () => ({
|
||||
api1,
|
||||
api2,
|
||||
categoryApi,
|
||||
designerApi,
|
||||
interiorApi,
|
||||
imageServerApi,
|
||||
keywordApi,
|
||||
marketApi,
|
||||
orderApi,
|
||||
productApi,
|
||||
});
|
||||
@@ -51,6 +52,7 @@ const context = async ({ req }) => {
|
||||
const res = await verifyToken({ token });
|
||||
return { auth: res };
|
||||
} else if (authHeader == 'Basic ZGV2OmRldg==') {
|
||||
// dev:dev remove later
|
||||
return { auth: 'during development' };
|
||||
}
|
||||
throw new AuthenticationError('Not authorized');
|
||||
|
||||
@@ -16,7 +16,7 @@ async function getDesigners(_, { limit }, { dataSources }) {
|
||||
return (<DesignerAPI>dataSources.designerApi).getDesigners(limit);
|
||||
}
|
||||
|
||||
async function getDesigner(_, { id }, { dataSources }) {
|
||||
async function getDesigner(parent, { id }, { dataSources }) {
|
||||
return (<DesignerAPI>dataSources.designerApi).getDesignerById(id);
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -1,9 +1,16 @@
|
||||
import { orderQueryTypeDefs, orderTypeDefs } from './orders-resolver';
|
||||
import { productQueryTypeDefs, productTypeDefs } from './products-resolver';
|
||||
import {
|
||||
productQueryTypeDefs,
|
||||
productTypeDefs,
|
||||
productMutationTypeDefs,
|
||||
} from './products-resolver';
|
||||
import { designerQueryTypeDefs, designerTypeDefs } from './designers-resolver';
|
||||
import { categoryQueryTypeDefs, categoryTypeDefs } from './categories-resolver';
|
||||
import { DateTimeResolver, DateResolver, JSONResolver } from 'graphql-scalars';
|
||||
import { keywordsQueryTypeDefs, keywordTypeDefs } from './keywords-resolver';
|
||||
import { marketsQueryTypeDefs, marketTypeDefs } from './markets-resolver';
|
||||
|
||||
import { DateTimeResolver, DateResolver, JSONResolver } from 'graphql-scalars';
|
||||
import { interiorsQueryTypeDefs, interiorTypeDefs } from './interiors-resolver';
|
||||
const resolvers = {
|
||||
Query: {
|
||||
...orderQueryTypeDefs,
|
||||
@@ -11,12 +18,19 @@ const resolvers = {
|
||||
...designerQueryTypeDefs,
|
||||
...categoryQueryTypeDefs,
|
||||
...keywordsQueryTypeDefs,
|
||||
...marketsQueryTypeDefs,
|
||||
...interiorsQueryTypeDefs,
|
||||
},
|
||||
Mutation: {
|
||||
...productMutationTypeDefs,
|
||||
},
|
||||
...orderTypeDefs,
|
||||
...productTypeDefs,
|
||||
...designerTypeDefs,
|
||||
...categoryTypeDefs,
|
||||
...keywordTypeDefs,
|
||||
...marketTypeDefs,
|
||||
...interiorTypeDefs,
|
||||
DateTime: DateTimeResolver,
|
||||
Date: DateResolver,
|
||||
JSON: JSONResolver,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IResolverObject } from 'graphql-tools';
|
||||
import { InteriorAPI } from '../datasources/interior-api';
|
||||
import { InteriorsFilterInput } from '../types/interior-types';
|
||||
|
||||
const Interior: IResolverObject = {};
|
||||
|
||||
async function getInteriors(_, args: InteriorsFilterInput, { dataSources }) {
|
||||
return (<InteriorAPI>dataSources.interiorApi).getInteriors(args.filter);
|
||||
}
|
||||
|
||||
export const interiorTypeDefs = { Interior };
|
||||
|
||||
export const interiorsQueryTypeDefs = {
|
||||
interiors: getInteriors,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IResolverObject } from 'graphql-tools';
|
||||
import { MarketAPI } from '../datasources/market-api';
|
||||
|
||||
const Market: IResolverObject = {};
|
||||
|
||||
async function getMarkets(_, _args, { dataSources }) {
|
||||
return (<MarketAPI>dataSources.marketApi).getMarkets();
|
||||
}
|
||||
|
||||
async function getMarket(_, { name }, { dataSources }) {
|
||||
return (<MarketAPI>dataSources.marketApi).getMarketByName(name);
|
||||
}
|
||||
|
||||
export const marketTypeDefs = { Market };
|
||||
|
||||
export const marketsQueryTypeDefs = {
|
||||
markets: getMarkets,
|
||||
market: getMarket,
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IResolverObject } from 'graphql-tools';
|
||||
import { MarketAPI } from '../datasources/market-api';
|
||||
import { OrderAPI } from '../datasources/order-api';
|
||||
import { FilterInput } from '../types/types';
|
||||
|
||||
@@ -14,7 +15,7 @@ const Order: IResolverObject = {
|
||||
return (<OrderAPI>dataSources.orderApi).getOrderRowsByOrderId(id);
|
||||
},
|
||||
async market({ market }, _args, { dataSources }) {
|
||||
return (<OrderAPI>dataSources.orderApi).getMarketByName(market);
|
||||
return (<MarketAPI>dataSources.marketApi).getMarketByName(market);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { IResolverObject } from 'graphql-tools';
|
||||
import { Api2DataSource } from '../datasources/api2-datasource';
|
||||
import { OrderAPI } from '../datasources/order-api';
|
||||
import { ProductAPI } from '../datasources/product-api';
|
||||
import { Product } from '../types/product-types';
|
||||
import { CategoryAPI } from '../datasources/category-api';
|
||||
import { Category } from '../types/category-types';
|
||||
import { KeywordAPI } from '../datasources/keyword-api';
|
||||
import { Keyword } from '../types/keyword-types';
|
||||
import { MarketAPI } from '../datasources/market-api';
|
||||
import { InteriorAPI } from '../datasources/interior-api';
|
||||
|
||||
const ProductBlacklist: IResolverObject = {
|
||||
async market(parent, _args, { dataSources }) {
|
||||
return (<OrderAPI>dataSources.orderApi).getMarketById(parent.marketId);
|
||||
return (<MarketAPI>dataSources.marketApi).getMarketById(parent.marketId);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,24 +20,22 @@ const Product: IResolverObject = {
|
||||
return (<CategoryAPI>dataSources.categoryApi).getProductCategories(id);
|
||||
},
|
||||
async designer({ designerId }, args, { dataSources }) {
|
||||
if (!designerId) {
|
||||
return null;
|
||||
}
|
||||
return dataSources.designerApi.getDesignerById(designerId);
|
||||
},
|
||||
async keywords({ id }, _args, { dataSources }): Promise<Array<Keyword>> {
|
||||
return (<KeywordAPI>dataSources.keywordApi).getProductKeywords(id);
|
||||
},
|
||||
async api1json({ id }, _args, { dataSources }): Promise<JSON> {
|
||||
return dataSources.api1.getProduct(id);
|
||||
async related({ id }, _args, { dataSources }) {
|
||||
return (<ProductAPI>dataSources.productApi).getRelatedProducts(id);
|
||||
},
|
||||
};
|
||||
|
||||
const PrintProduct: IResolverObject = {
|
||||
async price({ id }, { market, width, height, sku }, { dataSources }) {
|
||||
return (<Api2DataSource>dataSources.api2).getPrice(
|
||||
market,
|
||||
width,
|
||||
height,
|
||||
sku,
|
||||
);
|
||||
async interiors({ id }, _args, { dataSources }) {
|
||||
return (<InteriorAPI>dataSources.interiorApi).getPrintProductInteriors(id);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,3 +65,11 @@ export const productQueryTypeDefs = {
|
||||
products: getProducts,
|
||||
product: getProduct,
|
||||
};
|
||||
|
||||
// Mutations below
|
||||
|
||||
export const productMutationTypeDefs = {
|
||||
addKeyword(_, { productId, keywordId }, { dataSources }) {
|
||||
return { result: 'OK' };
|
||||
},
|
||||
};
|
||||
|
||||
+94
-8
@@ -19,6 +19,26 @@ const typeDefs = gql`
|
||||
keyword(id: Int!): Keyword
|
||||
designers(limit: Int): [Designer]
|
||||
designer(id: Int!): Designer
|
||||
markets: [Market]
|
||||
market(name: String!): Market
|
||||
interiors(filter: InteriorsFilterInput): [Interior]
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
addKeyword(productId: Int!, keywordId: Int!): MutationResult
|
||||
}
|
||||
|
||||
type MutationResult {
|
||||
result: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Only return valid interiors for product, type and orientation
|
||||
"""
|
||||
input InteriorsFilterInput {
|
||||
productId: Int!
|
||||
type: InteriorType!
|
||||
orientation: Orientation!
|
||||
}
|
||||
|
||||
input DateInput {
|
||||
@@ -134,8 +154,8 @@ const typeDefs = gql`
|
||||
designerPath: String
|
||||
discountType: String
|
||||
discountValue: Float
|
||||
displayHeight: Int
|
||||
displayWidth: Int
|
||||
displayHeight: Float
|
||||
displayWidth: Float
|
||||
edge: String
|
||||
frameColor: String
|
||||
framed: Int
|
||||
@@ -175,6 +195,7 @@ const typeDefs = gql`
|
||||
id: ID!
|
||||
name: String
|
||||
path: String
|
||||
pathNumeric: String
|
||||
isLeaf: Int
|
||||
depth: Int
|
||||
childCount: Int
|
||||
@@ -185,6 +206,7 @@ const typeDefs = gql`
|
||||
|
||||
type Product {
|
||||
id: ID!
|
||||
stockid: Int
|
||||
path: String!
|
||||
visible: Boolean!
|
||||
browsable: Boolean!
|
||||
@@ -196,15 +218,53 @@ const typeDefs = gql`
|
||||
designerId: Int
|
||||
designer: Designer
|
||||
keywords: [Keyword]
|
||||
ref1: String
|
||||
ref2: String
|
||||
ref3: String
|
||||
related: [Product]
|
||||
wallpaperTypes: [ProductWallpaperType]
|
||||
fields: ProductFields
|
||||
orientation: Orientation
|
||||
"""
|
||||
Will be single value return in later release
|
||||
Will be single values return in later release
|
||||
"""
|
||||
type: [ProductType]
|
||||
# Below are for debug
|
||||
api1json: JSON
|
||||
# Below are for debug and will be removed soon
|
||||
fields_json: JSON
|
||||
}
|
||||
|
||||
type ProductFields {
|
||||
artNo: String!
|
||||
name: String!
|
||||
height: Int
|
||||
width: Int
|
||||
photowallResolution: Int
|
||||
canvasResolution: Int
|
||||
wallpaperResolution: Int
|
||||
copyright: String
|
||||
proportionsWarning: String
|
||||
marginWidthMax: Int
|
||||
marginWidthMin: Int
|
||||
marginHeightMax: Int
|
||||
marginHeightMin: Int
|
||||
focusXpoint: Float
|
||||
focusYpoint: Float
|
||||
focusXpoint2: Float
|
||||
focusYpoint2: Float
|
||||
batch: String
|
||||
imageResolution: Int
|
||||
printFileWidth: Int
|
||||
printFileHeight: Int
|
||||
printFileDpi: Int
|
||||
}
|
||||
|
||||
enum Orientation {
|
||||
UNKNOWN
|
||||
PORTRAIT
|
||||
LANDSCAPE
|
||||
SQUARE
|
||||
}
|
||||
|
||||
enum ProductGroup {
|
||||
PHOTO_WALLPAPER
|
||||
CANVAS
|
||||
@@ -223,6 +283,11 @@ const typeDefs = gql`
|
||||
ILLUSTRATION
|
||||
}
|
||||
|
||||
enum ProductWallpaperType {
|
||||
WALLMURAL
|
||||
DESIGN
|
||||
}
|
||||
|
||||
type ProductBlacklist {
|
||||
id: ID!
|
||||
groupId: Int!
|
||||
@@ -239,7 +304,28 @@ const typeDefs = gql`
|
||||
group: ProductGroup!
|
||||
inserted: DateTime!
|
||||
updated: DateTime
|
||||
price(market: Int, width: Int, height: Int, sku: String): JSON
|
||||
interiors: [Interior]
|
||||
}
|
||||
|
||||
enum InteriorType {
|
||||
WALLPAPER
|
||||
PAINTING
|
||||
POSTER
|
||||
FRAMED_PRINT
|
||||
}
|
||||
|
||||
type Interior {
|
||||
"""
|
||||
room id
|
||||
"""
|
||||
id: ID!
|
||||
roomName: String
|
||||
position: Int
|
||||
roomType: String
|
||||
uri: String
|
||||
roomNumber: Int
|
||||
type: InteriorType
|
||||
orientation: Orientation
|
||||
}
|
||||
|
||||
type Designer {
|
||||
@@ -255,7 +341,7 @@ const typeDefs = gql`
|
||||
}
|
||||
|
||||
type Keyword {
|
||||
id: Int!
|
||||
id: ID!
|
||||
value: String!
|
||||
type: KeywordType
|
||||
products: [Product]
|
||||
@@ -321,7 +407,7 @@ orderrows example
|
||||
}
|
||||
|
||||
|
||||
|
||||
types:
|
||||
"doityourselfframe"
|
||||
"fixed"
|
||||
"tiling"
|
||||
|
||||
@@ -2,6 +2,7 @@ export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
path: string;
|
||||
pathNumeric: string;
|
||||
isLeaf: number;
|
||||
depth: number;
|
||||
childCount: number;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Orientation } from './product-types';
|
||||
|
||||
export interface Interior {
|
||||
id: number;
|
||||
roomName: string;
|
||||
position: number;
|
||||
roomType: string;
|
||||
uri: string;
|
||||
roomNumber: number;
|
||||
type: InteriorType;
|
||||
orientation: Orientation;
|
||||
}
|
||||
|
||||
export enum InteriorType {
|
||||
WALLPAPER,
|
||||
PAINTING,
|
||||
POSTER,
|
||||
FRAMED_PRINT,
|
||||
}
|
||||
|
||||
export interface InteriorsFilter {
|
||||
productId: number;
|
||||
type: InteriorType;
|
||||
orientation: Orientation;
|
||||
}
|
||||
|
||||
export interface InteriorsFilterInput {
|
||||
filter?: InteriorsFilter;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Market {
|
||||
id: number;
|
||||
name: string;
|
||||
vat: number;
|
||||
currency: string;
|
||||
priceAdjustment: number;
|
||||
}
|
||||
@@ -1,13 +1,3 @@
|
||||
import { DateInput } from './types';
|
||||
|
||||
export interface Market {
|
||||
id: number;
|
||||
name: string;
|
||||
vat: number;
|
||||
currency: string;
|
||||
priceAdjustment: number;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: number;
|
||||
firstname: string;
|
||||
|
||||
@@ -15,6 +15,19 @@ export enum ProductType {
|
||||
PHOTO = 1,
|
||||
ILLUSTRATION = 2,
|
||||
}
|
||||
|
||||
export enum ProductWallpaperType {
|
||||
WALLMURAL = 1,
|
||||
DESIGN = 2,
|
||||
}
|
||||
|
||||
export enum Orientation {
|
||||
UNKNOWN = 0,
|
||||
PORTRAIT = 1,
|
||||
LANDSCAPE = 2,
|
||||
SQUARE = 3,
|
||||
}
|
||||
|
||||
export interface ProductBlacklist {
|
||||
id: number;
|
||||
groupId: number;
|
||||
@@ -34,16 +47,44 @@ export interface PrintProduct {
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
stockid: number;
|
||||
path: string;
|
||||
visible: boolean;
|
||||
browsable: boolean;
|
||||
inserted: Date;
|
||||
updated?: Date;
|
||||
orientation?: Orientation;
|
||||
printProducts: [PrintProduct];
|
||||
blacklisting: [ProductBlacklist];
|
||||
type: [ProductType];
|
||||
api1json: JSON;
|
||||
fields_json: JSON;
|
||||
fields: ProductFields;
|
||||
fields_json: JSON; // remove later
|
||||
}
|
||||
|
||||
// TODO: test stock products to see what props can be mandatory
|
||||
export interface ProductFields {
|
||||
artNo: string;
|
||||
name: string;
|
||||
height?: number;
|
||||
width?: number;
|
||||
photowallResolution?: number;
|
||||
canvasResolution?: number;
|
||||
wallpaperResolution?: number;
|
||||
copyright?: string;
|
||||
proportionsWarning?: string;
|
||||
marginWidthMax?: number;
|
||||
marginWidthMin?: number;
|
||||
marginHeightMax?: number;
|
||||
marginHeightMin?: number;
|
||||
focusXpoint?: number;
|
||||
focusYpoint?: number;
|
||||
focusXpoint2?: number;
|
||||
focusYpoint2?: number;
|
||||
batch?: string;
|
||||
imageResolution?: number;
|
||||
printFileWidth?: number;
|
||||
printFileHeight?: number;
|
||||
printFileDpi?: number;
|
||||
}
|
||||
|
||||
export function getProductGroupStringFromString(group: string): string {
|
||||
|
||||
Reference in New Issue
Block a user