From 10fa25b2307d6c0b66e51be2b52ca5ddeeca9b5c Mon Sep 17 00:00:00 2001 From: Fredrik Ringqvist <35255659+fredrikphotowall@users.noreply.github.com> Date: Tue, 29 Apr 2025 14:00:35 +0200 Subject: [PATCH] Add collections (#172) --- src/context.ts | 5 ++ src/datasources/collection-api.ts | 119 ++++++++++++++++++++++++++ src/resolvers/collections-resolver.ts | 21 +++++ src/resolvers/index.ts | 2 + src/resolvers/products-resolver.ts | 3 + src/schema.graphql | 9 ++ src/types/collection-types.ts | 5 ++ 7 files changed, 164 insertions(+) create mode 100644 src/datasources/collection-api.ts create mode 100644 src/resolvers/collections-resolver.ts create mode 100644 src/types/collection-types.ts diff --git a/src/context.ts b/src/context.ts index 4f48092..ec1bc7d 100644 --- a/src/context.ts +++ b/src/context.ts @@ -6,6 +6,7 @@ import { } from './cognito/cognito-client'; import { dbConfig } from './config'; import { CategoryAPI } from './datasources/category-api'; +import { CollectionAPI } from './datasources/collection-api'; import { DesignerAPI } from './datasources/designer-api'; import { ImageServerApi } from './datasources/imageserver-api'; import { InteriorAPI } from './datasources/interior-api'; @@ -37,6 +38,7 @@ export class ContextValue { keywordApi: KeywordAPI; orderApi: OrderAPI; productApi: ProductAPI; + collectionApi: CollectionAPI; }; constructor({ @@ -84,6 +86,8 @@ export class ContextValue { textsApi, printProductDefaultsApi, ); + const collectionApi = new CollectionAPI(options, knexConfig); + this.dataSources = { productApi, marketLocaleApi, @@ -96,6 +100,7 @@ export class ContextValue { printProductDefaultsApi, keywordApi, orderApi, + collectionApi, }; return this; diff --git a/src/datasources/collection-api.ts b/src/datasources/collection-api.ts new file mode 100644 index 0000000..abd0534 --- /dev/null +++ b/src/datasources/collection-api.ts @@ -0,0 +1,119 @@ +import { BaseSQLDataSource } from './BaseSQLDataSource'; +import { DataSourceOptions, Maybe } from '../types/types'; +import { ScopeAccess, Scopes } from '../cognito/access-control'; +import { Collection } from '../types/collection-types'; +import DataLoader from 'dataloader'; + +export class CollectionAPI extends BaseSQLDataSource { + productCollectionsLoader: DataLoader; + + constructor(options: DataSourceOptions, config) { + super(options, config); + + // DataLoader for batching product-to-collections lookups + this.productCollectionsLoader = new DataLoader( + async (productIds: readonly number[]) => { + return this.batchGetProductCollections(productIds); + }, + ); + } + + async getCollections(): Promise> { + ScopeAccess.validate(this.user).some([ + Scopes.PRODUCTS_READ, + Scopes.PRODUCTS_PUBLIC_READ, + ]); + const query = ` + SELECT + id, + name, + metadata + FROM editorial_pages + WHERE type = 'collection' AND deleted IS NULL + ORDER BY name + `; + const collections = await this.knex.raw(query).then((data) => { + return data.rows.map((row) => this.createCollectionFromRow(row)); + }); + return collections; + } + + async getCollection(id: number): Promise> { + ScopeAccess.validate(this.user).some([ + Scopes.PRODUCTS_READ, + Scopes.PRODUCTS_PUBLIC_READ, + ]); + const query = ` + SELECT + id, + name, + metadata + FROM editorial_pages + WHERE id = ? AND type = 'collection' AND deleted IS NULL + `; + const collection = await this.knex.raw(query, [id]).then((data) => { + if (data.rows.length === 0) { + return null; + } + return this.createCollectionFromRow(data.rows[0]); + }); + return collection; + } + + createCollectionFromRow(row: any): Collection { + let productIds: number[] = []; + if (row.metadata && Array.isArray(row.metadata.collection_products)) { + productIds = row.metadata.collection_products.map((id: string | number) => + Number(id), + ); + } + return { + id: row.id, + name: row.name, + productIds, + }; + } + + async getProductCollections(productId: number): Promise { + ScopeAccess.validate(this.user).some([ + Scopes.PRODUCTS_READ, + Scopes.PRODUCTS_PUBLIC_READ, + ]); + return this.productCollectionsLoader.load(productId); + } + + private async batchGetProductCollections( + productIds: readonly number[], + ): Promise { + const productIdsStr = productIds.map(String); + const query = ` + WITH collection_products AS ( + SELECT + e.id, + e.name, + e.metadata, + jsonb_array_elements_text(e.metadata->'collection_products') AS product_id + FROM editorial_pages e + WHERE e.type = 'collection' + AND e.deleted IS NULL + AND e.metadata->'collection_products' IS NOT NULL + ) + SELECT * FROM collection_products + WHERE product_id = ANY (?) + `; + const results = await this.knex.raw(query, [productIdsStr]); + // Group collections by product ID + const collectionsByProduct: { [key: string]: Collection[] } = {}; + productIdsStr.forEach((id) => { + collectionsByProduct[id] = []; + }); + results.rows.forEach((row) => { + const productId = row.product_id; + if (!collectionsByProduct[productId]) + collectionsByProduct[productId] = []; + collectionsByProduct[productId].push(this.createCollectionFromRow(row)); + }); + // Return collections in the same order as the input productIds + return productIdsStr.map((id) => collectionsByProduct[id] || []); + } +} diff --git a/src/resolvers/collections-resolver.ts b/src/resolvers/collections-resolver.ts new file mode 100644 index 0000000..da73df0 --- /dev/null +++ b/src/resolvers/collections-resolver.ts @@ -0,0 +1,21 @@ +import { Collection } from '../types/collection-types'; +import { CollectionAPI } from '../datasources/collection-api'; + +async function getCollections( + _, + _args, + { dataSources }, +): Promise { + return (dataSources.collectionApi).getCollections(); +} + +async function getCollection(_, { id }, { dataSources }): Promise { + return (dataSources.collectionApi).getCollection(id); +} + +export const collectionResolvers = { + Query: { + collections: getCollections, + collection: getCollection, + }, +}; diff --git a/src/resolvers/index.ts b/src/resolvers/index.ts index 1931ceb..3370bba 100644 --- a/src/resolvers/index.ts +++ b/src/resolvers/index.ts @@ -15,6 +15,7 @@ import { marketsQueryTypeDefs, marketTypeDefs, } from './markets-locale-resolver'; +import { collectionResolvers } from './collections-resolver'; import { DateTimeResolver, DateResolver, JSONResolver } from 'graphql-scalars'; import { interiorsQueryTypeDefs, interiorTypeDefs } from './interiors-resolver'; @@ -32,6 +33,7 @@ const resolvers = { ...interiorsQueryTypeDefs, ...imagesQueryTypeDefs, ...TextsQueryTypeDefs, + ...collectionResolvers.Query, }, Mutation: { ...productMutationTypeDefs, diff --git a/src/resolvers/products-resolver.ts b/src/resolvers/products-resolver.ts index 3677a13..3280ee4 100644 --- a/src/resolvers/products-resolver.ts +++ b/src/resolvers/products-resolver.ts @@ -48,6 +48,9 @@ const Product = { async categories({ id }, _args, { dataSources }): Promise> { return (dataSources.categoryApi).getProductCategories(id); }, + async collections({ id }, _args, { dataSources }) { + return dataSources.collectionApi.getProductCollections(id); + }, async designer({ designerId }, _args, { dataSources }) { if (!designerId) { return null; diff --git a/src/schema.graphql b/src/schema.graphql index 443c050..cc73591 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -41,6 +41,8 @@ type Query { signedFileUploadURL(config: SignedUploadURLInput): SignedUploadURL @cacheControl(maxAge: 0) translate(input: TranslateInput!): StringResult! + collections: [Collection] + collection(id: Int!): Collection } type HealthCheck { @@ -260,6 +262,7 @@ type Product { printProducts(groups: [ProductGroup]): [PrintProduct] blacklisting: [ProductBlacklist] categories: [Category] + collections: [Collection] designerId: Int designer: Designer keywords: [Keyword] @@ -553,3 +556,9 @@ type Mutation { ): StringResult massUpdatePublishingDate(productIds: [Int]!, date: DateTime!): StringResult } + +type Collection { + id: ID! + name: String! + productIds: [Int!]! +} diff --git a/src/types/collection-types.ts b/src/types/collection-types.ts new file mode 100644 index 0000000..099bf38 --- /dev/null +++ b/src/types/collection-types.ts @@ -0,0 +1,5 @@ +export interface Collection { + id: string | number; + name: string; + productIds: number[]; +}