Skip to content
320 changes: 303 additions & 17 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ const typeDefs = `#graphql
name: String!
}
type FilterCount {
value: String!
label: String!
count: Int!
}
type Price {
storeCode: String!
price: Float!
Expand All @@ -30,17 +36,33 @@ const typeDefs = `#graphql
type Query {
products(
search: String
category: ID
categories: [ID!]
storeCodes: [String!]
sort: String = "relevance"
limit: Int = 20
offset: Int = 0
): [Product!]!
productsCount(
search: String
categories: [ID!]
storeCodes: [String!]
): Int!
product(ean: String!): Product
categories: [Category!]!
shoppingList(id: ID!): ShoppingList
categoryFilterCounts(
search: String
storeCodes: [String!]
): [FilterCount!]!
storeFilterCounts(
search: String
categories: [ID!]
): [FilterCount!]!
}
type ShoppingListItem {
Expand Down Expand Up @@ -72,6 +94,124 @@ const typeDefs = `#graphql

const resolvers = {
Query: {
categoryFilterCounts: async (
_parent: unknown,
args: {
search?: string;
storeCodes?: string[];
},
) => {
const search = args.search ?? null;
const storeCodes = args.storeCodes ?? [];

const result = await pool.query(
`
WITH RECURSIVE category_tree AS (
SELECT
id,
name,
parent_id,
id AS root_id,
name AS root_name
FROM categories
WHERE parent_id IS NULL
UNION
SELECT
c.id,
c.name,
c.parent_id,
ct.root_id,
ct.root_name
FROM categories c
INNER JOIN category_tree ct
ON c.parent_id = ct.id
)
SELECT
ct.root_id::text AS value,
ct.root_name AS label,
COUNT(DISTINCT p.ean)::integer AS count
FROM category_tree ct
INNER JOIN products p
ON p.category_id = ct.id
WHERE
(
$1::text IS NULL
OR p.name ILIKE '%' || $1 || '%'
)
AND (
cardinality($2::text[]) = 0
OR EXISTS (
SELECT 1
FROM prices pr
WHERE pr.ean = p.ean
AND pr.store_code = ANY($2::text[])
)
)
GROUP BY ct.root_id, ct.root_name
HAVING COUNT(DISTINCT p.ean) > 0
ORDER BY count DESC, label
`,
[search, storeCodes],
);

return result.rows;
},


storeFilterCounts: async (
_parent: unknown,
args: {
search?: string;
categories?: string[];
},
) => {
const search = args.search ?? null;
const categories = args.categories ?? [];

const result = await pool.query(
`
WITH RECURSIVE selected_categories AS (
SELECT id
FROM categories
WHERE id = ANY($2::integer[])
UNION
SELECT c.id
FROM categories c
INNER JOIN selected_categories sc
ON c.parent_id = sc.id
)
SELECT
pr.store_code AS value,
pr.store_code AS label,
COUNT(DISTINCT p.ean)::integer AS count
FROM prices pr
INNER JOIN products p
ON p.ean = pr.ean
WHERE
(
$1::text IS NULL
OR p.name ILIKE '%' || $1 || '%'
)
AND (
cardinality($2::integer[]) = 0
OR p.category_id IN (
SELECT id FROM selected_categories
)
)
GROUP BY pr.store_code
HAVING COUNT(DISTINCT p.ean) > 0
ORDER BY count DESC, value
`,
[search, categories],
);

return result.rows;
},

categories: async () => {
const result = await pool.query(`
SELECT
Expand Down Expand Up @@ -103,44 +243,190 @@ const resolvers = {
return result.rows[0] ?? null;
},


products: async (
_parent: unknown,
args: {
search?: string;
category?: string;
categories?: string[];
storeCodes?: string[];
sort?: string;
limit?: number;
offset?: number;
},
) => {
const search = args.search ?? null;
const category = args.category ?? null;
const limit = args.limit ?? 20;
const offset = args.offset ?? 0;
const categories = args.categories ?? [];
const storeCodes = args.storeCodes ?? [];

const allowedSorts = [
"relevance",
"price_asc",
"price_desc",
];

const sort = allowedSorts.includes(args.sort ?? "")
? args.sort
: "relevance";

const limit = Math.min(
Math.max(args.limit ?? 20, 1),
100,
);

const offset = Math.max(args.offset ?? 0, 0);

const result = await pool.query(
`
WITH RECURSIVE selected_categories AS (
SELECT id
FROM categories
WHERE id = ANY($2::integer[])
UNION
SELECT c.id
FROM categories c
INNER JOIN selected_categories sc
ON c.parent_id = sc.id
),
matching_products AS (
SELECT
p.ean,
p.name,
p.brand,
p.image_url AS "imageUrl",
p.category_id AS "categoryId",
(
SELECT MIN(pr.price)
FROM prices pr
WHERE pr.ean = p.ean
AND (
cardinality($3::text[]) = 0
OR pr.store_code = ANY($3::text[])
)
) AS sort_price
FROM products p
WHERE
(
$1::text IS NULL
OR p.name ILIKE '%' || $1 || '%'
)
AND (
cardinality($2::integer[]) = 0
OR p.category_id IN (
SELECT id FROM selected_categories
)
)
AND (
cardinality($3::text[]) = 0
OR EXISTS (
SELECT 1
FROM prices pr
WHERE pr.ean = p.ean
AND pr.store_code = ANY($3::text[])
)
)
)
SELECT
ean,
name,
brand,
image_url AS "imageUrl",
category_id AS "categoryId"
FROM products
WHERE
($1::text IS NULL OR name ILIKE '%' || $1 || '%')
AND
($2::integer IS NULL OR category_id = $2)
ORDER BY name
LIMIT $3
OFFSET $4
"imageUrl",
"categoryId"
FROM matching_products
ORDER BY
CASE
WHEN $6::text = 'relevance' THEN
CASE
WHEN LOWER(name) = LOWER($1::text) THEN 0
WHEN name ILIKE $1::text || '%' THEN 1
ELSE 2
END
END ASC NULLS LAST,
CASE
WHEN $6::text = 'price_asc'
THEN sort_price
END ASC NULLS LAST,
CASE
WHEN $6::text = 'price_desc'
THEN sort_price
END DESC NULLS LAST,
name ASC,
ean ASC
LIMIT $4
OFFSET $5
`,
[search, category, limit, offset],
[
search,
categories,
storeCodes,
limit,
offset,
sort,
],
);

return result.rows;
},

productsCount: async (
_parent: unknown,
args: {
search?: string;
categories?: string[];
storeCodes?: string[];
},
) => {
const search = args.search ?? null;
const categories = args.categories ?? [];
const storeCodes = args.storeCodes ?? [];

const result = await pool.query(
`
WITH RECURSIVE selected_categories AS (
SELECT id
FROM categories
WHERE id = ANY($2::integer[])
UNION
SELECT c.id
FROM categories c
INNER JOIN selected_categories sc
ON c.parent_id = sc.id
)
SELECT COUNT(*)::integer AS count
FROM products p
WHERE
(
$1::text IS NULL
OR p.name ILIKE '%' || $1 || '%'
)
AND (
cardinality($2::integer[]) = 0
OR p.category_id IN (
SELECT id FROM selected_categories
)
)
AND (
cardinality($3::text[]) = 0
OR EXISTS (
SELECT 1
FROM prices pr
WHERE pr.ean = p.ean
AND pr.store_code = ANY($3::text[])
)
)
`,
[search, categories, storeCodes],
);

return result.rows[0].count;
},

shoppingList: async (
_parent: unknown,
Expand Down
Loading